@0xmaxma/claude-gateway 1.0.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 (192) hide show
  1. package/README.md +791 -0
  2. package/config.template.json +87 -0
  3. package/dist/agent/context-isolation.d.ts +28 -0
  4. package/dist/agent/context-isolation.d.ts.map +1 -0
  5. package/dist/agent/context-isolation.js +80 -0
  6. package/dist/agent/context-isolation.js.map +1 -0
  7. package/dist/agent/runner.d.ts +211 -0
  8. package/dist/agent/runner.d.ts.map +1 -0
  9. package/dist/agent/runner.js +1802 -0
  10. package/dist/agent/runner.js.map +1 -0
  11. package/dist/agent/workspace-loader.d.ts +31 -0
  12. package/dist/agent/workspace-loader.d.ts.map +1 -0
  13. package/dist/agent/workspace-loader.js +207 -0
  14. package/dist/agent/workspace-loader.js.map +1 -0
  15. package/dist/api/auth.d.ts +25 -0
  16. package/dist/api/auth.d.ts.map +1 -0
  17. package/dist/api/auth.js +77 -0
  18. package/dist/api/auth.js.map +1 -0
  19. package/dist/api/cron-router.d.ts +22 -0
  20. package/dist/api/cron-router.d.ts.map +1 -0
  21. package/dist/api/cron-router.js +197 -0
  22. package/dist/api/cron-router.js.map +1 -0
  23. package/dist/api/gateway-router.d.ts +52 -0
  24. package/dist/api/gateway-router.d.ts.map +1 -0
  25. package/dist/api/gateway-router.js +215 -0
  26. package/dist/api/gateway-router.js.map +1 -0
  27. package/dist/api/router.d.ts +5 -0
  28. package/dist/api/router.d.ts.map +1 -0
  29. package/dist/api/router.js +1098 -0
  30. package/dist/api/router.js.map +1 -0
  31. package/dist/api/skills-router.d.ts +5 -0
  32. package/dist/api/skills-router.d.ts.map +1 -0
  33. package/dist/api/skills-router.js +424 -0
  34. package/dist/api/skills-router.js.map +1 -0
  35. package/dist/api/workspace-router.d.ts +4 -0
  36. package/dist/api/workspace-router.d.ts.map +1 -0
  37. package/dist/api/workspace-router.js +149 -0
  38. package/dist/api/workspace-router.js.map +1 -0
  39. package/dist/config/loader.d.ts +16 -0
  40. package/dist/config/loader.d.ts.map +1 -0
  41. package/dist/config/loader.js +235 -0
  42. package/dist/config/loader.js.map +1 -0
  43. package/dist/config/migrator.d.ts +70 -0
  44. package/dist/config/migrator.d.ts.map +1 -0
  45. package/dist/config/migrator.js +343 -0
  46. package/dist/config/migrator.js.map +1 -0
  47. package/dist/config/watcher.d.ts +30 -0
  48. package/dist/config/watcher.d.ts.map +1 -0
  49. package/dist/config/watcher.js +170 -0
  50. package/dist/config/watcher.js.map +1 -0
  51. package/dist/cron/manager.d.ts +48 -0
  52. package/dist/cron/manager.d.ts.map +1 -0
  53. package/dist/cron/manager.js +580 -0
  54. package/dist/cron/manager.js.map +1 -0
  55. package/dist/cron/scheduler.d.ts +50 -0
  56. package/dist/cron/scheduler.d.ts.map +1 -0
  57. package/dist/cron/scheduler.js +231 -0
  58. package/dist/cron/scheduler.js.map +1 -0
  59. package/dist/discord/receiver.d.ts +17 -0
  60. package/dist/discord/receiver.d.ts.map +1 -0
  61. package/dist/discord/receiver.js +108 -0
  62. package/dist/discord/receiver.js.map +1 -0
  63. package/dist/heartbeat/history.d.ts +24 -0
  64. package/dist/heartbeat/history.d.ts.map +1 -0
  65. package/dist/heartbeat/history.js +50 -0
  66. package/dist/heartbeat/history.js.map +1 -0
  67. package/dist/heartbeat/parser.d.ts +26 -0
  68. package/dist/heartbeat/parser.d.ts.map +1 -0
  69. package/dist/heartbeat/parser.js +283 -0
  70. package/dist/heartbeat/parser.js.map +1 -0
  71. package/dist/history/cleanup.d.ts +21 -0
  72. package/dist/history/cleanup.d.ts.map +1 -0
  73. package/dist/history/cleanup.js +171 -0
  74. package/dist/history/cleanup.js.map +1 -0
  75. package/dist/history/db.d.ts +27 -0
  76. package/dist/history/db.d.ts.map +1 -0
  77. package/dist/history/db.js +323 -0
  78. package/dist/history/db.js.map +1 -0
  79. package/dist/history/media-store.d.ts +27 -0
  80. package/dist/history/media-store.d.ts.map +1 -0
  81. package/dist/history/media-store.js +158 -0
  82. package/dist/history/media-store.js.map +1 -0
  83. package/dist/history/types.d.ts +62 -0
  84. package/dist/history/types.d.ts.map +1 -0
  85. package/dist/history/types.js +3 -0
  86. package/dist/history/types.js.map +1 -0
  87. package/dist/index.d.ts +3 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +483 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +81 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/memory/manager.d.ts +45 -0
  96. package/dist/memory/manager.d.ts.map +1 -0
  97. package/dist/memory/manager.js +178 -0
  98. package/dist/memory/manager.js.map +1 -0
  99. package/dist/session/compactor.d.ts +22 -0
  100. package/dist/session/compactor.d.ts.map +1 -0
  101. package/dist/session/compactor.js +151 -0
  102. package/dist/session/compactor.js.map +1 -0
  103. package/dist/session/process.d.ts +76 -0
  104. package/dist/session/process.d.ts.map +1 -0
  105. package/dist/session/process.js +729 -0
  106. package/dist/session/process.js.map +1 -0
  107. package/dist/session/store.d.ts +102 -0
  108. package/dist/session/store.d.ts.map +1 -0
  109. package/dist/session/store.js +533 -0
  110. package/dist/session/store.js.map +1 -0
  111. package/dist/skills/index.d.ts +9 -0
  112. package/dist/skills/index.d.ts.map +1 -0
  113. package/dist/skills/index.js +15 -0
  114. package/dist/skills/index.js.map +1 -0
  115. package/dist/skills/invoker.d.ts +16 -0
  116. package/dist/skills/invoker.d.ts.map +1 -0
  117. package/dist/skills/invoker.js +36 -0
  118. package/dist/skills/invoker.js.map +1 -0
  119. package/dist/skills/loader.d.ts +23 -0
  120. package/dist/skills/loader.d.ts.map +1 -0
  121. package/dist/skills/loader.js +183 -0
  122. package/dist/skills/loader.js.map +1 -0
  123. package/dist/skills/parser.d.ts +55 -0
  124. package/dist/skills/parser.d.ts.map +1 -0
  125. package/dist/skills/parser.js +178 -0
  126. package/dist/skills/parser.js.map +1 -0
  127. package/dist/skills/sync.d.ts +10 -0
  128. package/dist/skills/sync.d.ts.map +1 -0
  129. package/dist/skills/sync.js +130 -0
  130. package/dist/skills/sync.js.map +1 -0
  131. package/dist/skills/watcher.d.ts +16 -0
  132. package/dist/skills/watcher.d.ts.map +1 -0
  133. package/dist/skills/watcher.js +25 -0
  134. package/dist/skills/watcher.js.map +1 -0
  135. package/dist/telegram/markdown.d.ts +25 -0
  136. package/dist/telegram/markdown.d.ts.map +1 -0
  137. package/dist/telegram/markdown.js +237 -0
  138. package/dist/telegram/markdown.js.map +1 -0
  139. package/dist/telegram/receiver.d.ts +17 -0
  140. package/dist/telegram/receiver.d.ts.map +1 -0
  141. package/dist/telegram/receiver.js +104 -0
  142. package/dist/telegram/receiver.js.map +1 -0
  143. package/dist/types.d.ts +224 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +3 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/ui/web-ui.d.ts +6 -0
  148. package/dist/ui/web-ui.d.ts.map +1 -0
  149. package/dist/ui/web-ui.js +227 -0
  150. package/dist/ui/web-ui.js.map +1 -0
  151. package/dist/watch/factory.d.ts +26 -0
  152. package/dist/watch/factory.d.ts.map +1 -0
  153. package/dist/watch/factory.js +43 -0
  154. package/dist/watch/factory.js.map +1 -0
  155. package/dist/webhook/manager.d.ts +34 -0
  156. package/dist/webhook/manager.d.ts.map +1 -0
  157. package/dist/webhook/manager.js +120 -0
  158. package/dist/webhook/manager.js.map +1 -0
  159. package/mcp/bun.lock +264 -0
  160. package/mcp/channel-manager.ts +115 -0
  161. package/mcp/package.json +15 -0
  162. package/mcp/router.ts +69 -0
  163. package/mcp/server.ts +129 -0
  164. package/mcp/tools/agent/handlers.ts +578 -0
  165. package/mcp/tools/agent/module.ts +163 -0
  166. package/mcp/tools/browser/module.ts +321 -0
  167. package/mcp/tools/browser/skills/open-browser/SKILL.md +89 -0
  168. package/mcp/tools/cron/client.ts +65 -0
  169. package/mcp/tools/cron/module.ts +158 -0
  170. package/mcp/tools/cron/skills/cron/SKILL.md +46 -0
  171. package/mcp/tools/discord/access.ts +197 -0
  172. package/mcp/tools/discord/client.ts +31 -0
  173. package/mcp/tools/discord/commands.ts +48 -0
  174. package/mcp/tools/discord/inbound.ts +53 -0
  175. package/mcp/tools/discord/module.ts +442 -0
  176. package/mcp/tools/discord/outbound.ts +90 -0
  177. package/mcp/tools/discord/receiver-server.ts +58 -0
  178. package/mcp/tools/discord/skills/access/SKILL.md +86 -0
  179. package/mcp/tools/discord/skills/configure/SKILL.md +53 -0
  180. package/mcp/tools/discord/threading.ts +37 -0
  181. package/mcp/tools/discord/types.ts +106 -0
  182. package/mcp/tools/skills/handlers.ts +196 -0
  183. package/mcp/tools/skills/module.ts +163 -0
  184. package/mcp/tools/telegram/module.ts +454 -0
  185. package/mcp/tools/telegram/pure.ts +224 -0
  186. package/mcp/tools/telegram/receiver-server.ts +1656 -0
  187. package/mcp/tools/telegram/skills/access/SKILL.md +169 -0
  188. package/mcp/tools/telegram/skills/configure/SKILL.md +118 -0
  189. package/mcp/tools/telegram/typing.ts +327 -0
  190. package/mcp/types.ts +128 -0
  191. package/package.json +62 -0
  192. package/resource/claude_gateway.svg +112 -0
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Discord channel module — implements ChannelModule interface.
3
+ */
4
+
5
+ import * as path from 'path';
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import * as https from 'https';
9
+ import * as http from 'http';
10
+ import { randomBytes } from 'crypto';
11
+ import type {
12
+ ChannelModule,
13
+ ChannelCapabilities,
14
+ ChannelAccountSnapshot,
15
+ McpToolDefinition,
16
+ McpToolResult,
17
+ ToolVisibility,
18
+ InboundMessageHandler,
19
+ ChannelId,
20
+ } from '../../types';
21
+ import { sendMessage } from './outbound';
22
+ import { loadAccess, saveAccess, gate } from './access';
23
+ import { maybeCreateThread } from './threading';
24
+ import { createMessageHandler } from './inbound';
25
+ import type { DiscordMessageContext } from './types';
26
+
27
+ const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
28
+
29
+ export class DiscordModule implements ChannelModule {
30
+ id = 'discord' as ChannelId;
31
+ toolVisibility: ToolVisibility = 'current-channel';
32
+ skillsDir = path.join(__dirname, 'skills');
33
+
34
+ capabilities: ChannelCapabilities = {
35
+ typingIndicator: false,
36
+ reactions: true,
37
+ editMessage: true,
38
+ fileAttachment: true,
39
+ threadReply: true,
40
+ maxMessageLength: 2000,
41
+ markupFormat: 'markdown',
42
+ };
43
+
44
+ private client: any = null;
45
+ private stateDir: string;
46
+ private inboxDir: string;
47
+ private running = false;
48
+ private lastMessageAt?: number;
49
+ private lastError?: string;
50
+
51
+ constructor() {
52
+ this.stateDir = process.env.DISCORD_STATE_DIR
53
+ ?? path.join(os.homedir(), '.claude', 'channels', 'discord');
54
+ this.inboxDir = path.join(this.stateDir, 'inbox');
55
+ }
56
+
57
+ isEnabled(): boolean {
58
+ return Boolean(this.getToken());
59
+ }
60
+
61
+ private getToken(): string | undefined {
62
+ if (process.env.DISCORD_BOT_TOKEN) return process.env.DISCORD_BOT_TOKEN;
63
+ const envFile = path.join(this.stateDir, '.env');
64
+ try {
65
+ for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
66
+ const m = line.match(/^DISCORD_BOT_TOKEN=(.*)$/);
67
+ if (m) return m[1];
68
+ }
69
+ } catch {}
70
+ return undefined;
71
+ }
72
+
73
+ getTools(): McpToolDefinition[] {
74
+ return [
75
+ {
76
+ name: 'discord_reply',
77
+ description:
78
+ 'Send a message to a Discord channel, thread, or DM. Pass channel_id from the inbound message. Optionally pass reply_to (message_id) and files (absolute paths).',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ channel_id: { type: 'string' },
83
+ text: { type: 'string' },
84
+ reply_to: { type: 'string', description: 'Message ID to reply to.' },
85
+ files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach.' },
86
+ embed: { type: 'boolean', description: 'Use embed for long responses.' },
87
+ },
88
+ required: ['channel_id', 'text'],
89
+ },
90
+ },
91
+ {
92
+ name: 'discord_react',
93
+ description: 'Add an emoji reaction to a Discord message.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ channel_id: { type: 'string' },
98
+ message_id: { type: 'string' },
99
+ emoji: { type: 'string' },
100
+ },
101
+ required: ['channel_id', 'message_id', 'emoji'],
102
+ },
103
+ },
104
+ {
105
+ name: 'discord_edit_message',
106
+ description: "Edit a message the bot previously sent.",
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ channel_id: { type: 'string' },
111
+ message_id: { type: 'string' },
112
+ text: { type: 'string' },
113
+ },
114
+ required: ['channel_id', 'message_id', 'text'],
115
+ },
116
+ },
117
+ {
118
+ name: 'discord_download_attachment',
119
+ description: 'Download a file from a Discord CDN URL to the local inbox.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ url: { type: 'string', description: 'CDN URL from attachmentFileId.' },
124
+ filename: { type: 'string', description: 'Optional filename override.' },
125
+ },
126
+ required: ['url'],
127
+ },
128
+ },
129
+ {
130
+ name: 'discord_create_thread',
131
+ description: 'Create a public thread in a Discord channel.',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ channel_id: { type: 'string' },
136
+ name: { type: 'string', description: 'Thread name (max 100 chars).' },
137
+ message_id: { type: 'string', description: 'Source message to start the thread from.' },
138
+ },
139
+ required: ['channel_id', 'name'],
140
+ },
141
+ },
142
+ ];
143
+ }
144
+
145
+ async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
146
+ if (!this.client) {
147
+ return { content: [{ type: 'text', text: 'Discord client not initialized' }], isError: true };
148
+ }
149
+ try {
150
+ switch (name) {
151
+ case 'discord_reply': return await this.handleReply(args);
152
+ case 'discord_react': return await this.handleReact(args);
153
+ case 'discord_edit_message': return await this.handleEditMessage(args);
154
+ case 'discord_download_attachment': return await this.handleDownloadAttachment(args);
155
+ case 'discord_create_thread': return await this.handleCreateThread(args);
156
+ default:
157
+ return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
158
+ }
159
+ } catch (err) {
160
+ const msg = err instanceof Error ? err.message : String(err);
161
+ return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true };
162
+ }
163
+ }
164
+
165
+ async initBot(): Promise<void> {
166
+ const token = this.getToken();
167
+ if (!token) throw new Error('DISCORD_BOT_TOKEN not configured');
168
+ const { createDiscordClient } = await import('./client');
169
+ this.client = await createDiscordClient(token);
170
+ this.running = true;
171
+ }
172
+
173
+ async start(handler: InboundMessageHandler, signal: AbortSignal): Promise<void> {
174
+ if (!this.client) {
175
+ await this.initBot();
176
+ }
177
+
178
+ const autoThread = process.env.DISCORD_AUTO_THREAD === 'true';
179
+ const autoArchive = parseInt(process.env.DISCORD_AUTO_THREAD_ARCHIVE ?? '60', 10);
180
+ const useEmbeds = process.env.DISCORD_USE_EMBEDS === 'true';
181
+ const agentId = process.env.GATEWAY_AGENT_ID ?? 'discord';
182
+
183
+ const stateDir = this.stateDir;
184
+ const loadAccessFn = () => loadAccess(stateDir);
185
+ const saveAccessFn = (a: ReturnType<typeof loadAccess>) => saveAccess(stateDir, a);
186
+
187
+ // Permissive config — gate() already handled access, inbound.ts skips re-check
188
+ const permissiveConfig = {
189
+ botToken: this.getToken()!,
190
+ dmPolicy: 'open' as const,
191
+ dmAllowlist: [] as string[],
192
+ guildAllowlist: [] as string[],
193
+ channelAllowlist: [] as string[],
194
+ autoThread,
195
+ autoThreadArchiveMinutes: autoArchive as 60 | 1440 | 4320 | 10080,
196
+ maxMessageLength: 2000,
197
+ useEmbeds,
198
+ };
199
+
200
+ const permissiveAccessConfig = {
201
+ dmPolicy: 'open' as const,
202
+ dmAllowlist: [] as string[],
203
+ guildAllowlist: [] as string[],
204
+ channelAllowlist: [] as string[],
205
+ roleAllowlist: [] as string[],
206
+ };
207
+
208
+ const msgHandler = createMessageHandler(agentId, handler, permissiveConfig, permissiveAccessConfig);
209
+
210
+ this.client.on('messageCreate', async (msg: any) => {
211
+ if (msg.author?.bot) return;
212
+ if (msg.system) return;
213
+
214
+ this.lastMessageAt = Date.now();
215
+
216
+ const isDM = !msg.guild;
217
+ const isThread = msg.channel?.isThread?.() ?? false;
218
+
219
+ const context: DiscordMessageContext = {
220
+ guildId: msg.guildId ?? null,
221
+ channelId: msg.channelId,
222
+ threadId: isThread ? msg.channelId : null,
223
+ userId: msg.author.id,
224
+ username: msg.author.username,
225
+ messageId: msg.id,
226
+ isDM,
227
+ isThread,
228
+ };
229
+
230
+ const access = loadAccessFn();
231
+ const result = gate(access, context, saveAccessFn, () => randomBytes(3).toString('hex'));
232
+
233
+ if (result.action === 'drop') return;
234
+
235
+ if (result.action === 'pair') {
236
+ try {
237
+ await msg.channel.send(
238
+ `Pairing required — run in Claude Code:\n\n/discord:access pair ${result.code}`,
239
+ );
240
+ } catch {}
241
+ return;
242
+ }
243
+
244
+ // action === 'deliver'
245
+ // Start typing indicator before handing off to runner
246
+ const channelId = context.channelId;
247
+ const typingFileDir = path.join(this.stateDir, 'typing');
248
+ try {
249
+ fs.mkdirSync(typingFileDir, { recursive: true });
250
+ fs.writeFileSync(path.join(typingFileDir, channelId), '');
251
+ await fetch(`https://discord.com/api/v10/channels/${channelId}/typing`, {
252
+ method: 'POST',
253
+ headers: { Authorization: `Bot ${this.getToken()!}`, 'Content-Length': '0' },
254
+ });
255
+ } catch {}
256
+
257
+ await msgHandler(msg);
258
+ if (autoThread) {
259
+ await maybeCreateThread(msg, autoThread, autoArchive).catch(() => {});
260
+ }
261
+ });
262
+
263
+ const approvedDir = path.join(this.stateDir, 'approved');
264
+ const approvedInterval = setInterval(() => { void this.checkApprovals(approvedDir); }, 5000);
265
+ approvedInterval.unref();
266
+
267
+ const typingDir = path.join(this.stateDir, 'typing');
268
+ const forwardInterval = setInterval(() => { void this.processForwardFiles(typingDir); }, 1000);
269
+ forwardInterval.unref();
270
+
271
+ // Send typing indicator every 8s for all active channels (Discord typing lasts ~10s)
272
+ const typingInterval = setInterval(() => { void this.processTypingSignals(typingDir, this.getToken()!); }, 8000);
273
+ typingInterval.unref();
274
+
275
+ signal.addEventListener('abort', () => {
276
+ this.running = false;
277
+ clearInterval(approvedInterval);
278
+ clearInterval(forwardInterval);
279
+ clearInterval(typingInterval);
280
+ this.client?.destroy?.();
281
+ });
282
+
283
+ await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve()));
284
+ }
285
+
286
+ private async processForwardFiles(typingDir: string): Promise<void> {
287
+ let files: string[];
288
+ try { files = fs.readdirSync(typingDir); } catch { return; }
289
+
290
+ for (const file of files) {
291
+ if (!file.endsWith('.forward')) continue;
292
+ const filePath = path.join(typingDir, file);
293
+ const channelId = file.slice(0, -'.forward'.length);
294
+ let text: string;
295
+ let parseMode: undefined | 'html';
296
+ try {
297
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
298
+ try {
299
+ const parsed = JSON.parse(raw) as { text: string; format: string };
300
+ text = parsed.text;
301
+ parseMode = parsed.format === 'html' ? 'html' : undefined;
302
+ } catch {
303
+ text = raw;
304
+ }
305
+ fs.rmSync(filePath, { force: true });
306
+ } catch { continue; }
307
+
308
+ if (!text) continue;
309
+ try {
310
+ const channel = await this.client.channels.fetch(channelId);
311
+ if (parseMode === 'html') {
312
+ // Discord doesn't support HTML — strip tags and send plain text
313
+ text = text.replace(/<[^>]*>/g, '');
314
+ }
315
+ await sendMessage(channel, text, {});
316
+ } catch { /* non-fatal */ }
317
+ }
318
+ }
319
+
320
+ private async processTypingSignals(typingDir: string, token: string): Promise<void> {
321
+ let files: string[];
322
+ try { files = fs.readdirSync(typingDir); } catch { return; }
323
+
324
+ for (const file of files) {
325
+ // Only act on plain files (no extension) — these are active typing channels
326
+ if (file.includes('.')) continue;
327
+ const channelId = file;
328
+ const errorFile = path.join(typingDir, `${channelId}.error`);
329
+ if (fs.existsSync(errorFile)) {
330
+ // Error signalled by runner — clean up both files and stop typing
331
+ try { fs.rmSync(path.join(typingDir, channelId), { force: true }); } catch {}
332
+ try { fs.rmSync(errorFile, { force: true }); } catch {}
333
+ continue;
334
+ }
335
+ try {
336
+ await fetch(`https://discord.com/api/v10/channels/${channelId}/typing`, {
337
+ method: 'POST',
338
+ headers: { Authorization: `Bot ${this.getToken()!}`, 'Content-Length': '0' },
339
+ });
340
+ } catch {}
341
+ }
342
+ }
343
+
344
+ private async checkApprovals(approvedDir: string): Promise<void> {
345
+ let files: string[];
346
+ try { files = fs.readdirSync(approvedDir); } catch { return; }
347
+
348
+ for (const userId of files) {
349
+ const file = path.join(approvedDir, userId);
350
+ let channelId: string;
351
+ try { channelId = fs.readFileSync(file, 'utf8').trim(); } catch { continue; }
352
+
353
+ try {
354
+ const channel = await this.client.channels.fetch(channelId);
355
+ await channel.send("You're connected! Send me a message to get started.");
356
+ } catch {}
357
+
358
+ try { fs.unlinkSync(file); } catch {}
359
+ }
360
+ }
361
+
362
+ getSnapshot(): ChannelAccountSnapshot {
363
+ return {
364
+ accountId: this.id,
365
+ running: this.running,
366
+ configured: this.isEnabled(),
367
+ lastMessageAt: this.lastMessageAt,
368
+ lastError: this.lastError,
369
+ };
370
+ }
371
+
372
+ private async handleReply(args: Record<string, unknown>): Promise<McpToolResult> {
373
+ const channel = await this.client.channels.fetch(args.channel_id as string);
374
+ const text = args.text as string;
375
+ const replyTo = args.reply_to as string | undefined;
376
+ const files = (args.files as string[] | undefined) ?? [];
377
+ const useEmbed = Boolean(args.embed);
378
+
379
+ for (const f of files) {
380
+ const st = fs.statSync(f);
381
+ if (st.size > MAX_ATTACHMENT_BYTES) {
382
+ throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`);
383
+ }
384
+ }
385
+
386
+ const sent = await sendMessage(channel, text, { replyTo, files, useEmbed });
387
+ const ids = sent.map(m => m.id).join(', ');
388
+ return { content: [{ type: 'text', text: `sent (${sent.length === 1 ? `id: ${ids}` : `ids: ${ids}`})` }] };
389
+ }
390
+
391
+ private async handleReact(args: Record<string, unknown>): Promise<McpToolResult> {
392
+ const channel = await this.client.channels.fetch(args.channel_id as string);
393
+ const message = await channel.messages.fetch(args.message_id as string);
394
+ await message.react(args.emoji as string);
395
+ return { content: [{ type: 'text', text: 'reacted' }] };
396
+ }
397
+
398
+ private async handleEditMessage(args: Record<string, unknown>): Promise<McpToolResult> {
399
+ const channel = await this.client.channels.fetch(args.channel_id as string);
400
+ const message = await channel.messages.fetch(args.message_id as string);
401
+ const edited = await message.edit(args.text as string);
402
+ return { content: [{ type: 'text', text: `edited (id: ${edited.id})` }] };
403
+ }
404
+
405
+ private async handleDownloadAttachment(args: Record<string, unknown>): Promise<McpToolResult> {
406
+ const url = args.url as string;
407
+ const filename = (args.filename as string | undefined) ?? path.basename(url.split('?')[0]);
408
+ const ext = path.extname(filename).replace(/[^a-zA-Z0-9.]/g, '') || '.bin';
409
+ const dlPath = path.join(this.inboxDir, `${Date.now()}-${filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
410
+
411
+ fs.mkdirSync(this.inboxDir, { recursive: true });
412
+
413
+ await new Promise<void>((resolve, reject) => {
414
+ const proto = url.startsWith('https') ? https : http;
415
+ const file = fs.createWriteStream(dlPath);
416
+ proto.get(url, res => {
417
+ if (res.statusCode && res.statusCode >= 400) {
418
+ file.close();
419
+ reject(new Error(`download failed: HTTP ${res.statusCode}`));
420
+ return;
421
+ }
422
+ res.pipe(file);
423
+ file.on('finish', () => { file.close(); resolve(); });
424
+ }).on('error', err => { file.close(); reject(err); });
425
+ });
426
+
427
+ return { content: [{ type: 'text', text: dlPath }] };
428
+ }
429
+
430
+ private async handleCreateThread(args: Record<string, unknown>): Promise<McpToolResult> {
431
+ const channel = await this.client.channels.fetch(args.channel_id as string);
432
+ const name = (args.name as string).slice(0, 100);
433
+ let thread: any;
434
+ if (args.message_id) {
435
+ const message = await channel.messages.fetch(args.message_id as string);
436
+ thread = await message.startThread({ name, autoArchiveDuration: 60 });
437
+ } else {
438
+ thread = await channel.threads.create({ name, autoArchiveDuration: 60 });
439
+ }
440
+ return { content: [{ type: 'text', text: `thread created (id: ${thread.id})` }] };
441
+ }
442
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Discord outbound adapter — openclaw pattern from send.outbound.ts.
3
+ * Uses local SendableChannel interface; no top-level discord.js import.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import type { SendableChannel, SentMessage, EmbedData, FileAttachment } from './types';
9
+
10
+ const MAX_MESSAGE_LENGTH = 2000;
11
+ const MAX_EMBED_DESCRIPTION = 4096;
12
+
13
+ export async function sendMessage(
14
+ channel: SendableChannel,
15
+ text: string,
16
+ options: {
17
+ replyTo?: string;
18
+ files?: string[];
19
+ useEmbed?: boolean;
20
+ } = {},
21
+ ): Promise<SentMessage[]> {
22
+ const sent: SentMessage[] = [];
23
+
24
+ if (text.length > MAX_MESSAGE_LENGTH && options.useEmbed) {
25
+ const embedText = text.slice(0, MAX_EMBED_DESCRIPTION);
26
+ const embed: EmbedData = { description: embedText };
27
+ const msg = await channel.send({ embeds: [embed] });
28
+ sent.push(msg);
29
+
30
+ const remaining = text.slice(MAX_EMBED_DESCRIPTION);
31
+ if (remaining) {
32
+ const chunks = chunkText(remaining, MAX_MESSAGE_LENGTH);
33
+ for (const chunk of chunks) {
34
+ sent.push(await channel.send({ content: chunk }));
35
+ }
36
+ }
37
+ } else {
38
+ const chunks = chunkText(text, MAX_MESSAGE_LENGTH);
39
+ const replyRef = options.replyTo;
40
+ for (let i = 0; i < chunks.length; i++) {
41
+ const sendOpts: Parameters<SendableChannel['send']>[0] = { content: chunks[i] };
42
+ if (replyRef && i === 0) sendOpts.reply = { messageReference: replyRef };
43
+ sent.push(await channel.send(sendOpts));
44
+ }
45
+ }
46
+
47
+ for (const filePath of options.files ?? []) {
48
+ const attachment: FileAttachment = {
49
+ attachment: filePath,
50
+ name: path.basename(filePath),
51
+ };
52
+ sent.push(await channel.send({ files: [attachment] }));
53
+ }
54
+
55
+ return sent;
56
+ }
57
+
58
+ export function chunkText(text: string, limit: number): string[] {
59
+ if (text.length <= limit) return [text];
60
+ const chunks: string[] = [];
61
+ let rest = text;
62
+ while (rest.length > limit) {
63
+ const para = rest.lastIndexOf('\n\n', limit);
64
+ const nl = rest.lastIndexOf('\n', limit);
65
+ const sp = rest.lastIndexOf(' ', limit);
66
+ const cut = para > limit / 2 ? para : nl > limit / 2 ? nl : sp > 0 ? sp : limit;
67
+ chunks.push(rest.slice(0, cut));
68
+ rest = rest.slice(cut).replace(/^\n+/, '');
69
+ }
70
+ if (rest) chunks.push(rest);
71
+ return chunks;
72
+ }
73
+
74
+ export function validateFilePath(filePath: string, inboxDir: string): void {
75
+ let real: string;
76
+ try {
77
+ real = fs.realpathSync(filePath);
78
+ } catch {
79
+ return;
80
+ }
81
+ let inboxReal: string;
82
+ try {
83
+ inboxReal = fs.realpathSync(inboxDir);
84
+ } catch {
85
+ return;
86
+ }
87
+ if (real.startsWith(path.dirname(inboxReal) + path.sep) && !real.startsWith(inboxReal + path.sep)) {
88
+ throw new Error(`refusing to send state directory file: ${filePath}`);
89
+ }
90
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Standalone Discord receiver — spawned by DiscordReceiver in src/discord/receiver.ts.
4
+ * Connects to Discord, runs the access gate, and POSTs inbound messages to
5
+ * CLAUDE_CHANNEL_CALLBACK so the AgentRunner can route them to a session.
6
+ */
7
+
8
+ import { DiscordModule } from './module';
9
+ import type { InboundMessage } from '../../types';
10
+
11
+ const CALLBACK_URL = process.env.CLAUDE_CHANNEL_CALLBACK;
12
+ if (!CALLBACK_URL) {
13
+ process.stderr.write('discord receiver: CLAUDE_CHANNEL_CALLBACK required\n');
14
+ process.exit(1);
15
+ }
16
+
17
+ async function postCallback(inbound: InboundMessage): Promise<void> {
18
+ const meta: Record<string, string> = {
19
+ source: 'discord',
20
+ chat_id: inbound.chatId,
21
+ user: inbound.senderId,
22
+ message_id: inbound.messageId ?? '',
23
+ ts: new Date(inbound.ts).toISOString(),
24
+ };
25
+
26
+ if (inbound.attachmentFileId) {
27
+ meta['attachment_file_id'] = inbound.attachmentFileId;
28
+ }
29
+
30
+ try {
31
+ await fetch(CALLBACK_URL!, {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({ content: inbound.text ?? '', meta }),
35
+ });
36
+ } catch (err) {
37
+ process.stderr.write(`discord receiver: callback POST failed: ${err}\n`);
38
+ }
39
+ }
40
+
41
+ const mod = new DiscordModule();
42
+ const controller = new AbortController();
43
+
44
+ function shutdown(): void {
45
+ process.stderr.write('discord receiver: shutting down\n');
46
+ controller.abort();
47
+ setTimeout(() => process.exit(0), 2000);
48
+ }
49
+ process.on('SIGTERM', shutdown);
50
+ process.on('SIGINT', shutdown);
51
+
52
+ process.stderr.write('discord receiver: starting\n');
53
+ try {
54
+ await mod.start(postCallback, controller.signal);
55
+ } catch (err) {
56
+ process.stderr.write(`discord receiver: fatal error: ${err}\n`);
57
+ process.exit(1);
58
+ }
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: discord-access
3
+ description: Manage Discord channel access — guild/channel allowlists, DM policy, user/role allowlists.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # /gateway:discord-access — Manage Discord access
8
+
9
+ Use this skill to view or update Discord access settings stored at `$DISCORD_STATE_DIR/access.json`.
10
+
11
+ ## Commands
12
+
13
+ ### Show current access config
14
+ ```
15
+ /gateway:discord-access show
16
+ ```
17
+
18
+ ### Set DM policy
19
+ ```
20
+ /gateway:discord-access dm-policy <pairing|allowlist|disabled>
21
+ ```
22
+
23
+ ### Add/remove user from DM allowlist
24
+ ```
25
+ /gateway:discord-access dm-allow <user_id>
26
+ /gateway:discord-access dm-deny <user_id>
27
+ ```
28
+
29
+ ### Approve a pending pairing code
30
+ ```
31
+ /gateway:discord-access pair <code>
32
+ ```
33
+
34
+ ### Deny/remove a pending pairing code
35
+ ```
36
+ /gateway:discord-access deny <code>
37
+ ```
38
+
39
+ ### Add/remove guild from allowlist
40
+ ```
41
+ /gateway:discord-access guild-allow <guild_id>
42
+ /gateway:discord-access guild-deny <guild_id>
43
+ ```
44
+
45
+ ### Add/remove channel from allowlist
46
+ ```
47
+ /gateway:discord-access channel-allow <channel_id>
48
+ /gateway:discord-access channel-deny <channel_id>
49
+ ```
50
+
51
+ ## Access file format (`access.json`)
52
+
53
+ ```json
54
+ {
55
+ "dmPolicy": "pairing",
56
+ "allowFrom": [],
57
+ "guildAllowlist": [],
58
+ "channelAllowlist": [],
59
+ "roleAllowlist": [],
60
+ "pending": {}
61
+ }
62
+ ```
63
+
64
+ ## Implementation
65
+
66
+ Read `$DISCORD_STATE_DIR/access.json`, apply the requested change, write it back.
67
+ If the file does not exist, create it with defaults (dmPolicy: pairing, empty lists, empty pending).
68
+
69
+ ### `pair <code>` implementation
70
+ 1. Load `$DISCORD_STATE_DIR/access.json`
71
+ 2. Look up `pending[code]` — error if not found
72
+ 3. Check `pending[code].expiresAt > Date.now()` — error if expired
73
+ 4. Add `pending[code].senderId` to `allowFrom` (deduped)
74
+ 5. Delete `pending[code]`
75
+ 6. Write `$DISCORD_STATE_DIR/approved/<senderId>` with the `channelId` as file contents
76
+ 7. Save `access.json`
77
+ 8. Report: "Paired! User `<senderId>` added to allowFrom. Bot will send confirmation within 5s."
78
+
79
+ ### `deny <code>` implementation
80
+ 1. Load `$DISCORD_STATE_DIR/access.json`
81
+ 2. Look up `pending[code]` — error if not found
82
+ 3. Delete `pending[code]`
83
+ 4. Save `access.json`
84
+ 5. Report: "Code `<code>` rejected and removed."
85
+
86
+ Always confirm the change made and show the new config.