@dobby.ai/dobby 0.1.0

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 (174) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +267 -0
  3. package/README.md +382 -0
  4. package/ROADMAP.md +34 -0
  5. package/config/cron.example.json +9 -0
  6. package/config/gateway.example.json +128 -0
  7. package/config/models.custom.example.json +27 -0
  8. package/dist/src/agent/event-forwarder.js +341 -0
  9. package/dist/src/agent/tests/event-forwarder.test.js +113 -0
  10. package/dist/src/cli/commands/config.js +243 -0
  11. package/dist/src/cli/commands/configure.js +61 -0
  12. package/dist/src/cli/commands/cron.js +288 -0
  13. package/dist/src/cli/commands/doctor.js +189 -0
  14. package/dist/src/cli/commands/extension.js +151 -0
  15. package/dist/src/cli/commands/init.js +286 -0
  16. package/dist/src/cli/commands/start.js +177 -0
  17. package/dist/src/cli/commands/topology.js +254 -0
  18. package/dist/src/cli/index.js +8 -0
  19. package/dist/src/cli/program.js +386 -0
  20. package/dist/src/cli/shared/config-io.js +223 -0
  21. package/dist/src/cli/shared/config-mutators.js +345 -0
  22. package/dist/src/cli/shared/config-path.js +207 -0
  23. package/dist/src/cli/shared/config-schema.js +159 -0
  24. package/dist/src/cli/shared/config-types.js +1 -0
  25. package/dist/src/cli/shared/configure-sections.js +429 -0
  26. package/dist/src/cli/shared/discord-config.js +12 -0
  27. package/dist/src/cli/shared/init-catalog.js +115 -0
  28. package/dist/src/cli/shared/init-models-file.js +65 -0
  29. package/dist/src/cli/shared/presets.js +86 -0
  30. package/dist/src/cli/shared/runtime.js +29 -0
  31. package/dist/src/cli/shared/schema-prompts.js +325 -0
  32. package/dist/src/cli/tests/config-command.test.js +42 -0
  33. package/dist/src/cli/tests/config-io.test.js +64 -0
  34. package/dist/src/cli/tests/config-mutators.test.js +47 -0
  35. package/dist/src/cli/tests/config-path.test.js +21 -0
  36. package/dist/src/cli/tests/discord-config.test.js +23 -0
  37. package/dist/src/cli/tests/doctor.test.js +107 -0
  38. package/dist/src/cli/tests/init-catalog.test.js +87 -0
  39. package/dist/src/cli/tests/presets.test.js +41 -0
  40. package/dist/src/cli/tests/program-options.test.js +92 -0
  41. package/dist/src/cli/tests/routing-config.test.js +199 -0
  42. package/dist/src/cli/tests/routing-legacy.test.js +191 -0
  43. package/dist/src/core/control-command.js +12 -0
  44. package/dist/src/core/dedup-store.js +92 -0
  45. package/dist/src/core/gateway.js +432 -0
  46. package/dist/src/core/routing.js +306 -0
  47. package/dist/src/core/runtime-registry.js +119 -0
  48. package/dist/src/core/tests/control-command.test.js +17 -0
  49. package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
  50. package/dist/src/core/tests/runtime-registry.test.js +116 -0
  51. package/dist/src/core/tests/typing-controller.test.js +103 -0
  52. package/dist/src/core/types.js +1 -0
  53. package/dist/src/core/typing-controller.js +88 -0
  54. package/dist/src/cron/config.js +114 -0
  55. package/dist/src/cron/schedule.js +49 -0
  56. package/dist/src/cron/service.js +196 -0
  57. package/dist/src/cron/store.js +142 -0
  58. package/dist/src/cron/types.js +1 -0
  59. package/dist/src/extension/loader.js +97 -0
  60. package/dist/src/extension/manager.js +269 -0
  61. package/dist/src/extension/manifest.js +21 -0
  62. package/dist/src/extension/registry.js +137 -0
  63. package/dist/src/main.js +6 -0
  64. package/dist/src/sandbox/executor.js +1 -0
  65. package/dist/src/sandbox/host-executor.js +111 -0
  66. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
  67. package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
  68. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
  69. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
  70. package/docs/MVP.md +135 -0
  71. package/docs/RUNBOOK.md +242 -0
  72. package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
  73. package/package.json +43 -0
  74. package/plugins/connector-discord/dobby.manifest.json +18 -0
  75. package/plugins/connector-discord/index.js +1 -0
  76. package/plugins/connector-discord/package-lock.json +360 -0
  77. package/plugins/connector-discord/package.json +38 -0
  78. package/plugins/connector-discord/src/connector.ts +350 -0
  79. package/plugins/connector-discord/src/contribution.ts +21 -0
  80. package/plugins/connector-discord/src/mapper.ts +102 -0
  81. package/plugins/connector-discord/tsconfig.json +19 -0
  82. package/plugins/connector-feishu/dobby.manifest.json +18 -0
  83. package/plugins/connector-feishu/index.js +1 -0
  84. package/plugins/connector-feishu/package-lock.json +618 -0
  85. package/plugins/connector-feishu/package.json +38 -0
  86. package/plugins/connector-feishu/src/connector.ts +343 -0
  87. package/plugins/connector-feishu/src/contribution.ts +26 -0
  88. package/plugins/connector-feishu/src/mapper.ts +401 -0
  89. package/plugins/connector-feishu/tsconfig.json +19 -0
  90. package/plugins/plugin-sdk/index.d.ts +261 -0
  91. package/plugins/plugin-sdk/index.js +1 -0
  92. package/plugins/plugin-sdk/package-lock.json +12 -0
  93. package/plugins/plugin-sdk/package.json +22 -0
  94. package/plugins/provider-claude/dobby.manifest.json +17 -0
  95. package/plugins/provider-claude/index.js +1 -0
  96. package/plugins/provider-claude/package-lock.json +3398 -0
  97. package/plugins/provider-claude/package.json +39 -0
  98. package/plugins/provider-claude/src/contribution.ts +1018 -0
  99. package/plugins/provider-claude/tsconfig.json +19 -0
  100. package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
  101. package/plugins/provider-claude-cli/index.js +1 -0
  102. package/plugins/provider-claude-cli/package-lock.json +2898 -0
  103. package/plugins/provider-claude-cli/package.json +38 -0
  104. package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
  105. package/plugins/provider-claude-cli/tsconfig.json +19 -0
  106. package/plugins/provider-pi/dobby.manifest.json +17 -0
  107. package/plugins/provider-pi/index.js +1 -0
  108. package/plugins/provider-pi/package-lock.json +3877 -0
  109. package/plugins/provider-pi/package.json +40 -0
  110. package/plugins/provider-pi/src/contribution.ts +476 -0
  111. package/plugins/provider-pi/tsconfig.json +19 -0
  112. package/plugins/sandbox-core/boxlite.js +1 -0
  113. package/plugins/sandbox-core/dobby.manifest.json +17 -0
  114. package/plugins/sandbox-core/docker.js +1 -0
  115. package/plugins/sandbox-core/package-lock.json +136 -0
  116. package/plugins/sandbox-core/package.json +39 -0
  117. package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
  118. package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
  119. package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
  120. package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
  121. package/plugins/sandbox-core/src/docker-executor.ts +217 -0
  122. package/plugins/sandbox-core/tsconfig.json +19 -0
  123. package/scripts/local-extensions.mjs +168 -0
  124. package/src/agent/event-forwarder.ts +414 -0
  125. package/src/cli/commands/config.ts +328 -0
  126. package/src/cli/commands/configure.ts +92 -0
  127. package/src/cli/commands/cron.ts +410 -0
  128. package/src/cli/commands/doctor.ts +230 -0
  129. package/src/cli/commands/extension.ts +205 -0
  130. package/src/cli/commands/init.ts +396 -0
  131. package/src/cli/commands/start.ts +223 -0
  132. package/src/cli/commands/topology.ts +383 -0
  133. package/src/cli/index.ts +9 -0
  134. package/src/cli/program.ts +465 -0
  135. package/src/cli/shared/config-io.ts +277 -0
  136. package/src/cli/shared/config-mutators.ts +440 -0
  137. package/src/cli/shared/config-schema.ts +228 -0
  138. package/src/cli/shared/config-types.ts +121 -0
  139. package/src/cli/shared/configure-sections.ts +551 -0
  140. package/src/cli/shared/discord-config.ts +14 -0
  141. package/src/cli/shared/init-catalog.ts +189 -0
  142. package/src/cli/shared/init-models-file.ts +77 -0
  143. package/src/cli/shared/runtime.ts +33 -0
  144. package/src/cli/shared/schema-prompts.ts +414 -0
  145. package/src/cli/tests/config-command.test.ts +56 -0
  146. package/src/cli/tests/config-io.test.ts +92 -0
  147. package/src/cli/tests/config-mutators.test.ts +59 -0
  148. package/src/cli/tests/doctor.test.ts +120 -0
  149. package/src/cli/tests/init-catalog.test.ts +96 -0
  150. package/src/cli/tests/program-options.test.ts +113 -0
  151. package/src/cli/tests/routing-config.test.ts +209 -0
  152. package/src/core/control-command.ts +12 -0
  153. package/src/core/dedup-store.ts +103 -0
  154. package/src/core/gateway.ts +607 -0
  155. package/src/core/routing.ts +379 -0
  156. package/src/core/runtime-registry.ts +141 -0
  157. package/src/core/tests/control-command.test.ts +20 -0
  158. package/src/core/tests/runtime-registry.test.ts +140 -0
  159. package/src/core/tests/typing-controller.test.ts +129 -0
  160. package/src/core/types.ts +318 -0
  161. package/src/core/typing-controller.ts +119 -0
  162. package/src/cron/config.ts +154 -0
  163. package/src/cron/schedule.ts +61 -0
  164. package/src/cron/service.ts +249 -0
  165. package/src/cron/store.ts +155 -0
  166. package/src/cron/types.ts +60 -0
  167. package/src/extension/loader.ts +145 -0
  168. package/src/extension/manager.ts +355 -0
  169. package/src/extension/manifest.ts +26 -0
  170. package/src/extension/registry.ts +229 -0
  171. package/src/main.ts +8 -0
  172. package/src/sandbox/executor.ts +44 -0
  173. package/src/sandbox/host-executor.ts +118 -0
  174. package/tsconfig.json +18 -0
@@ -0,0 +1,350 @@
1
+ import {
2
+ AttachmentBuilder,
3
+ Client,
4
+ GatewayIntentBits,
5
+ Partials,
6
+ type Message,
7
+ type MessageCreateOptions,
8
+ type SendableChannels,
9
+ } from "discord.js";
10
+ import type {
11
+ ConnectorCapabilities,
12
+ ConnectorContext,
13
+ ConnectorPlugin,
14
+ ConnectorSendResult,
15
+ ConnectorTypingEnvelope,
16
+ GatewayLogger,
17
+ OutboundEnvelope,
18
+ } from "@dobby.ai/plugin-sdk";
19
+ import { mapDiscordMessage } from "./mapper.js";
20
+
21
+ const DISCORD_MAX_CONTENT_LENGTH = 2000;
22
+ const DEFAULT_RECONNECT_STALE_MS = 60_000;
23
+ const DEFAULT_RECONNECT_CHECK_INTERVAL_MS = 10_000;
24
+
25
+ export interface DiscordConnectorConfig {
26
+ botName: string;
27
+ botToken: string;
28
+ reconnectStaleMs?: number;
29
+ reconnectCheckIntervalMs?: number;
30
+ }
31
+
32
+ function clampDiscordContent(text: string): string {
33
+ if (text.length <= DISCORD_MAX_CONTENT_LENGTH) {
34
+ return text;
35
+ }
36
+
37
+ const suffix = "\n...(truncated)";
38
+ const budget = DISCORD_MAX_CONTENT_LENGTH - suffix.length;
39
+ if (budget <= 0) {
40
+ return text.slice(0, DISCORD_MAX_CONTENT_LENGTH);
41
+ }
42
+
43
+ return `${text.slice(0, budget)}${suffix}`;
44
+ }
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return Boolean(value) && typeof value === "object";
48
+ }
49
+
50
+ export class DiscordConnector implements ConnectorPlugin {
51
+ readonly id: string;
52
+ readonly platform = "discord" as const;
53
+ readonly name = "discord";
54
+ readonly capabilities: ConnectorCapabilities = {
55
+ updateStrategy: "edit",
56
+ supportedSources: ["channel"],
57
+ supportsThread: true,
58
+ supportsTyping: true,
59
+ supportsFileUpload: true,
60
+ maxTextLength: DISCORD_MAX_CONTENT_LENGTH,
61
+ };
62
+
63
+ private client: Client | null = null;
64
+ private ctx: ConnectorContext | null = null;
65
+ private botUserId: string | null = null;
66
+ private botToken: string | null = null;
67
+ private reconnectWatchdog: NodeJS.Timeout | null = null;
68
+ private reconnectInFlight = false;
69
+ private lastHealthyAtMs = 0;
70
+ private stopped = false;
71
+
72
+ constructor(
73
+ id: string,
74
+ private readonly config: DiscordConnectorConfig,
75
+ private readonly attachmentsRoot: string,
76
+ private readonly logger: GatewayLogger,
77
+ ) {
78
+ this.id = id;
79
+ }
80
+
81
+ async start(ctx: ConnectorContext): Promise<void> {
82
+ if (this.client) {
83
+ this.logger.warn({ connectorId: this.id }, "Discord connector start called while already started");
84
+ return;
85
+ }
86
+
87
+ const token = this.config.botToken.trim();
88
+ if (!token) {
89
+ throw new Error("Discord bot token is empty");
90
+ }
91
+
92
+ this.ctx = ctx;
93
+ this.botToken = token;
94
+ this.stopped = false;
95
+ this.lastHealthyAtMs = Date.now();
96
+
97
+ this.client = this.createClient();
98
+ this.bindClientEventHandlers(this.client);
99
+ await this.client.login(token);
100
+ this.startReconnectWatchdog();
101
+ }
102
+
103
+ async send(message: OutboundEnvelope): Promise<ConnectorSendResult> {
104
+ if (!this.client) {
105
+ throw new Error("Discord connector is not started");
106
+ }
107
+
108
+ const channel = await this.fetchTextChannel(message.chatId);
109
+ const content = clampDiscordContent(message.text);
110
+ if (content !== message.text) {
111
+ this.logger.warn(
112
+ {
113
+ originalLength: message.text.length,
114
+ truncatedLength: content.length,
115
+ mode: message.mode,
116
+ chatId: message.chatId,
117
+ },
118
+ "Outbound Discord message exceeded 2000 characters and was truncated",
119
+ );
120
+ }
121
+
122
+ if (message.mode === "update") {
123
+ if (!message.targetMessageId) {
124
+ throw new Error("targetMessageId is required for update mode");
125
+ }
126
+
127
+ const existing = await channel.messages.fetch(message.targetMessageId);
128
+ const edited = await existing.edit({ content });
129
+ return { messageId: edited.id };
130
+ }
131
+
132
+ const options: MessageCreateOptions = {
133
+ content,
134
+ };
135
+
136
+ if (message.replyToMessageId) {
137
+ options.reply = { messageReference: message.replyToMessageId, failIfNotExists: false };
138
+ }
139
+
140
+ if (message.attachments && message.attachments.length > 0) {
141
+ options.files = message.attachments.map((attachment) =>
142
+ attachment.title
143
+ ? new AttachmentBuilder(attachment.localPath, { name: attachment.title })
144
+ : new AttachmentBuilder(attachment.localPath),
145
+ );
146
+ }
147
+
148
+ const sent = await channel.send(options);
149
+ return { messageId: sent.id };
150
+ }
151
+
152
+ async sendTyping(message: ConnectorTypingEnvelope): Promise<void> {
153
+ if (!this.client) {
154
+ throw new Error("Discord connector is not started");
155
+ }
156
+
157
+ const channel = await this.fetchTextChannel(message.chatId);
158
+ await channel.sendTyping();
159
+ }
160
+
161
+ async stop(): Promise<void> {
162
+ this.stopped = true;
163
+ this.stopReconnectWatchdog();
164
+ this.reconnectInFlight = false;
165
+ if (!this.client) return;
166
+
167
+ const client = this.client;
168
+ this.client = null;
169
+ client.removeAllListeners();
170
+ client.destroy();
171
+ this.ctx = null;
172
+ this.botUserId = null;
173
+ this.botToken = null;
174
+ this.lastHealthyAtMs = 0;
175
+ }
176
+
177
+ private createClient(): Client {
178
+ return new Client({
179
+ intents: [
180
+ GatewayIntentBits.Guilds,
181
+ GatewayIntentBits.GuildMessages,
182
+ GatewayIntentBits.MessageContent,
183
+ GatewayIntentBits.DirectMessages,
184
+ ],
185
+ partials: [Partials.Channel, Partials.Message],
186
+ });
187
+ }
188
+
189
+ private bindClientEventHandlers(client: Client): void {
190
+ client.once("clientReady", () => {
191
+ if (client !== this.client || !client.user) return;
192
+ this.botUserId = client.user.id;
193
+ this.lastHealthyAtMs = Date.now();
194
+ this.logger.info(
195
+ {
196
+ userId: this.botUserId,
197
+ userName: client.user.username,
198
+ configuredBotName: this.config.botName,
199
+ },
200
+ "Discord connector ready",
201
+ );
202
+ });
203
+
204
+ client.on("shardDisconnect", (event, shardId) => {
205
+ if (client !== this.client) return;
206
+ this.logger.warn(
207
+ {
208
+ shardId,
209
+ reconnecting: client.ws.shards.get(shardId)?.status === 5,
210
+ ...this.parseCloseEvent(event),
211
+ },
212
+ "Discord shard disconnected",
213
+ );
214
+ });
215
+
216
+ client.on("shardReconnecting", (shardId) => {
217
+ if (client !== this.client) return;
218
+ this.logger.warn({ shardId }, "Discord shard reconnecting");
219
+ });
220
+
221
+ client.on("shardResume", (shardId, replayedEvents) => {
222
+ if (client !== this.client) return;
223
+ this.lastHealthyAtMs = Date.now();
224
+ this.logger.info({ shardId, replayedEvents }, "Discord shard resumed");
225
+ });
226
+
227
+ client.on("error", (error) => {
228
+ if (client !== this.client) return;
229
+ this.logger.warn({ err: error }, "Discord client error");
230
+ });
231
+
232
+ client.on("shardError", (error, shardId) => {
233
+ if (client !== this.client) return;
234
+ this.logger.warn({ err: error, shardId }, "Discord shard error");
235
+ });
236
+
237
+ client.on("invalidated", () => {
238
+ if (client !== this.client) return;
239
+ this.logger.error("Discord session invalidated; forcing reconnect");
240
+ void this.forceReconnect("session_invalidated");
241
+ });
242
+
243
+ client.on("messageCreate", async (message: Message) => {
244
+ if (client !== this.client || !client.user || !this.ctx || !this.botUserId) return;
245
+
246
+ // v1 explicitly disables DM handling; only bound guild channels are processed.
247
+ if (!message.guildId) {
248
+ return;
249
+ }
250
+
251
+ if (message.author.bot) return;
252
+
253
+ const sourceId = message.channel.isThread() && message.channel.parentId ? message.channel.parentId : message.channelId;
254
+
255
+ const inbound = await mapDiscordMessage(
256
+ message,
257
+ this.id,
258
+ this.botUserId,
259
+ sourceId,
260
+ this.attachmentsRoot,
261
+ this.logger,
262
+ );
263
+ if (!inbound) return;
264
+
265
+ await this.ctx.emitInbound(inbound);
266
+ });
267
+ }
268
+
269
+ private startReconnectWatchdog(): void {
270
+ if (this.reconnectWatchdog) return;
271
+ const intervalMs = this.config.reconnectCheckIntervalMs ?? DEFAULT_RECONNECT_CHECK_INTERVAL_MS;
272
+ this.reconnectWatchdog = setInterval(() => {
273
+ void this.ensureConnected();
274
+ }, intervalMs);
275
+ }
276
+
277
+ private stopReconnectWatchdog(): void {
278
+ if (!this.reconnectWatchdog) return;
279
+ clearInterval(this.reconnectWatchdog);
280
+ this.reconnectWatchdog = null;
281
+ }
282
+
283
+ private async ensureConnected(): Promise<void> {
284
+ const client = this.client;
285
+ if (!client || this.stopped || this.reconnectInFlight) return;
286
+
287
+ if (client.isReady()) {
288
+ this.lastHealthyAtMs = Date.now();
289
+ return;
290
+ }
291
+
292
+ const staleMs = Date.now() - this.lastHealthyAtMs;
293
+ const thresholdMs = this.config.reconnectStaleMs ?? DEFAULT_RECONNECT_STALE_MS;
294
+ if (staleMs < thresholdMs) {
295
+ return;
296
+ }
297
+
298
+ this.logger.warn({ staleMs, thresholdMs }, "Discord connector remained not-ready for too long; forcing reconnect");
299
+ await this.forceReconnect("watchdog_not_ready");
300
+ }
301
+
302
+ private async forceReconnect(reason: string): Promise<void> {
303
+ if (this.stopped || this.reconnectInFlight || !this.botToken) {
304
+ return;
305
+ }
306
+
307
+ this.reconnectInFlight = true;
308
+ const previousClient = this.client;
309
+
310
+ try {
311
+ if (previousClient) {
312
+ previousClient.removeAllListeners();
313
+ previousClient.destroy();
314
+ }
315
+
316
+ this.botUserId = null;
317
+ this.lastHealthyAtMs = Date.now();
318
+
319
+ const nextClient = this.createClient();
320
+ this.client = nextClient;
321
+ this.bindClientEventHandlers(nextClient);
322
+ await nextClient.login(this.botToken);
323
+ this.logger.info({ reason }, "Discord reconnect login submitted");
324
+ } catch (error) {
325
+ this.logger.error({ err: error, reason }, "Failed to force Discord reconnect");
326
+ } finally {
327
+ this.reconnectInFlight = false;
328
+ }
329
+ }
330
+
331
+ private parseCloseEvent(event: unknown): Record<string, unknown> {
332
+ if (!isRecord(event)) return {};
333
+ const result: Record<string, unknown> = {};
334
+ if (typeof event.code === "number") result.code = event.code;
335
+ if (typeof event.reason === "string" && event.reason.length > 0) result.reason = event.reason;
336
+ if (typeof event.wasClean === "boolean") result.wasClean = event.wasClean;
337
+ return result;
338
+ }
339
+
340
+ private async fetchTextChannel(channelId: string): Promise<SendableChannels> {
341
+ if (!this.client) throw new Error("Discord connector is not started");
342
+
343
+ const channel = await this.client.channels.fetch(channelId);
344
+ if (!channel || !channel.isSendable()) {
345
+ throw new Error(`Discord channel '${channelId}' is not text-based`);
346
+ }
347
+
348
+ return channel;
349
+ }
350
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import type { ConnectorContributionModule } from "@dobby.ai/plugin-sdk";
3
+ import { DiscordConnector, type DiscordConnectorConfig } from "./connector.js";
4
+
5
+ const discordConnectorConfigSchema = z.object({
6
+ botName: z.string().min(1),
7
+ botToken: z.string().min(1),
8
+ reconnectStaleMs: z.number().int().positive().default(60_000),
9
+ reconnectCheckIntervalMs: z.number().int().positive().default(10_000),
10
+ });
11
+
12
+ export const connectorDiscordContribution: ConnectorContributionModule = {
13
+ kind: "connector",
14
+ configSchema: z.toJSONSchema(discordConnectorConfigSchema),
15
+ createInstance(options) {
16
+ const config = discordConnectorConfigSchema.parse(options.config) as DiscordConnectorConfig;
17
+ return new DiscordConnector(options.instanceId, config, options.attachmentsRoot, options.host.logger);
18
+ },
19
+ };
20
+
21
+ export default connectorDiscordContribution;
@@ -0,0 +1,102 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Message } from "discord.js";
4
+ import type { GatewayLogger, InboundAttachment, InboundEnvelope } from "@dobby.ai/plugin-sdk";
5
+
6
+ function stripBotMention(text: string, botUserId: string): string {
7
+ const mentionRegex = new RegExp(`<@!?${botUserId}>`, "g");
8
+ return text.replace(mentionRegex, "").trim();
9
+ }
10
+
11
+ function sanitizeFileName(value: string): string {
12
+ return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
13
+ }
14
+
15
+ async function downloadAttachment(url: string, targetPath: string): Promise<void> {
16
+ const response = await fetch(url);
17
+ if (!response.ok) {
18
+ throw new Error(`Failed to download attachment from ${url}: ${response.status}`);
19
+ }
20
+
21
+ const data = await response.arrayBuffer();
22
+ await writeFile(targetPath, Buffer.from(data));
23
+ }
24
+
25
+ function mapAttachmentBase(messageAttachment: {
26
+ id: string;
27
+ name: string | null;
28
+ contentType: string | null;
29
+ size: number;
30
+ url: string;
31
+ }): InboundAttachment {
32
+ return {
33
+ id: messageAttachment.id,
34
+ size: messageAttachment.size,
35
+ remoteUrl: messageAttachment.url,
36
+ ...(messageAttachment.name ? { fileName: messageAttachment.name } : {}),
37
+ ...(messageAttachment.contentType ? { mimeType: messageAttachment.contentType } : {}),
38
+ };
39
+ }
40
+
41
+ export async function mapDiscordMessage(
42
+ message: Message,
43
+ connectorId: string,
44
+ botUserId: string,
45
+ sourceId: string,
46
+ attachmentsRoot: string,
47
+ logger: GatewayLogger,
48
+ ): Promise<InboundEnvelope | null> {
49
+ if (message.author.bot) return null;
50
+
51
+ const isDirectMessage = message.guildId == null;
52
+ const mentionedBot = message.mentions.users.has(botUserId);
53
+
54
+ const chatId = message.channelId;
55
+ const threadId = message.channel.isThread() ? message.channelId : undefined;
56
+
57
+ const cleanedText = stripBotMention(message.content ?? "", botUserId);
58
+
59
+ const attachmentDir = join(attachmentsRoot, sourceId, message.id);
60
+ await mkdir(attachmentDir, { recursive: true });
61
+
62
+ const attachments: InboundAttachment[] = [];
63
+
64
+ for (const attachment of message.attachments.values()) {
65
+ const base = mapAttachmentBase(attachment);
66
+ const fileName = sanitizeFileName(attachment.name ?? attachment.id);
67
+ const localPath = join(attachmentDir, fileName);
68
+
69
+ try {
70
+ await downloadAttachment(attachment.url, localPath);
71
+ attachments.push({
72
+ ...base,
73
+ localPath,
74
+ });
75
+ } catch (error) {
76
+ logger.warn({ err: error, attachmentUrl: attachment.url }, "Failed to download Discord attachment; keeping metadata only");
77
+ attachments.push(base);
78
+ }
79
+ }
80
+
81
+ return {
82
+ connectorId,
83
+ platform: "discord",
84
+ accountId: botUserId,
85
+ source: {
86
+ type: "channel",
87
+ id: sourceId,
88
+ },
89
+ chatId,
90
+ messageId: message.id,
91
+ userId: message.author.id,
92
+ userName: message.author.username,
93
+ text: cleanedText,
94
+ attachments,
95
+ timestampMs: message.createdTimestamp,
96
+ raw: message.toJSON(),
97
+ isDirectMessage,
98
+ mentionedBot,
99
+ ...(message.guildId ? { guildId: message.guildId } : {}),
100
+ ...(threadId ? { threadId } : {}),
101
+ };
102
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "types": ["node"],
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "exactOptionalPropertyTypes": true,
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["src/**/*.ts"],
18
+ "exclude": ["dist", "node_modules"]
19
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "apiVersion": "1.0",
3
+ "name": "@dobby.ai/connector-feishu",
4
+ "version": "0.1.0",
5
+ "contributions": [
6
+ {
7
+ "id": "connector.feishu",
8
+ "kind": "connector",
9
+ "entry": "./dist/contribution.js",
10
+ "capabilities": {
11
+ "updateStrategy": "edit",
12
+ "supportsThread": true,
13
+ "supportsTyping": false,
14
+ "supportsFileUpload": false
15
+ }
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1 @@
1
+ export { connectorFeishuContribution as contribution, connectorFeishuContribution as default } from "./dist/contribution.js";