@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,158 @@
1
+ /**
2
+ * Cron tool module — implements ToolModule interface.
3
+ * Provides cron job management tools via the gateway REST API.
4
+ * Not a chat channel — tool-only module with "all-configured" visibility.
5
+ */
6
+
7
+ import * as path from 'path';
8
+ import type {
9
+ ToolModule,
10
+ McpToolDefinition,
11
+ McpToolResult,
12
+ ToolVisibility,
13
+ } from '../../types';
14
+ import { CronClient } from './client';
15
+
16
+ export class CronModule implements ToolModule {
17
+ id = 'cron';
18
+ toolVisibility: ToolVisibility = 'all-configured';
19
+ skillsDir = path.join(__dirname, 'skills');
20
+
21
+ private client: CronClient | null = null;
22
+
23
+ isEnabled(): boolean {
24
+ return Boolean(process.env.GATEWAY_API_URL && process.env.GATEWAY_AGENT_ID);
25
+ }
26
+
27
+ private getClient(): CronClient {
28
+ if (!this.client) {
29
+ const apiUrl = process.env.GATEWAY_API_URL!;
30
+ const agentId = process.env.GATEWAY_AGENT_ID!;
31
+ const apiKey = process.env.GATEWAY_API_KEY;
32
+ this.client = new CronClient(apiUrl, agentId, apiKey);
33
+ }
34
+ return this.client;
35
+ }
36
+
37
+ getTools(): McpToolDefinition[] {
38
+ return [
39
+ {
40
+ name: 'cron_list',
41
+ description: 'List scheduled cron jobs for this agent',
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: {},
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ {
49
+ name: 'cron_create',
50
+ description: 'Create a new cron job',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ name: { type: 'string', description: 'Job name' },
55
+ schedule: { type: 'string', description: '5-field cron expression' },
56
+ type: { type: 'string', enum: ['command', 'agent'], description: 'Job type' },
57
+ command: { type: 'string', description: 'Shell command (type=command)' },
58
+ prompt: { type: 'string', description: 'Agent prompt (type=agent)' },
59
+ telegram: { type: 'string', description: 'Telegram chat_id for response' },
60
+ discord: { type: 'string', description: 'Discord channel_id for agent response delivery' },
61
+ timeout_ms: { type: 'number', description: 'Timeout in milliseconds' },
62
+ scheduleKind: {
63
+ type: 'string',
64
+ enum: ['cron', 'at'],
65
+ description: 'Schedule type: "cron" for recurring (default), "at" for one-shot at a specific ISO datetime',
66
+ },
67
+ scheduleAt: {
68
+ type: 'string',
69
+ description: 'ISO 8601 datetime for one-shot execution (required when scheduleKind=at)',
70
+ },
71
+ deleteAfterRun: {
72
+ type: 'boolean',
73
+ description: 'Delete the job after it runs once. Defaults to true for scheduleKind=at, false for cron',
74
+ },
75
+ },
76
+ required: ['name', 'type'],
77
+ additionalProperties: false,
78
+ },
79
+ },
80
+ {
81
+ name: 'cron_delete',
82
+ description: 'Delete a cron job by ID',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ job_id: { type: 'string', description: 'Job ID to delete' },
87
+ },
88
+ required: ['job_id'],
89
+ additionalProperties: false,
90
+ },
91
+ },
92
+ {
93
+ name: 'cron_run',
94
+ description: 'Run a cron job immediately',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ job_id: { type: 'string', description: 'Job ID to run' },
99
+ },
100
+ required: ['job_id'],
101
+ additionalProperties: false,
102
+ },
103
+ },
104
+ {
105
+ name: 'cron_get_runs',
106
+ description: 'Get run history for a cron job',
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ job_id: { type: 'string', description: 'Job ID' },
111
+ },
112
+ required: ['job_id'],
113
+ additionalProperties: false,
114
+ },
115
+ },
116
+ ];
117
+ }
118
+
119
+ async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
120
+ const client = this.getClient();
121
+
122
+ try {
123
+ switch (name) {
124
+ case 'cron_list': {
125
+ const jobs = await client.list();
126
+ return { content: [{ type: 'text', text: JSON.stringify(jobs, null, 2) }] };
127
+ }
128
+ case 'cron_create': {
129
+ // Fix 3: Default deleteAfterRun=true for at-type one-shot jobs so they are
130
+ // automatically cleaned up after firing, preventing re-fire on gateway restart.
131
+ const scheduleKind = (args.scheduleKind as string | undefined) ?? 'cron';
132
+ const deleteAfterRun = args.deleteAfterRun !== undefined
133
+ ? args.deleteAfterRun
134
+ : scheduleKind === 'at';
135
+ const job = await client.create({ ...args, scheduleKind, deleteAfterRun });
136
+ return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }] };
137
+ }
138
+ case 'cron_delete': {
139
+ await client.delete(args.job_id as string);
140
+ return { content: [{ type: 'text', text: `deleted job ${args.job_id}` }] };
141
+ }
142
+ case 'cron_run': {
143
+ const run = await client.run(args.job_id as string);
144
+ return { content: [{ type: 'text', text: JSON.stringify(run, null, 2) }] };
145
+ }
146
+ case 'cron_get_runs': {
147
+ const runs = await client.getRuns(args.job_id as string);
148
+ return { content: [{ type: 'text', text: JSON.stringify(runs, null, 2) }] };
149
+ }
150
+ default:
151
+ return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
152
+ }
153
+ } catch (err) {
154
+ const msg = err instanceof Error ? err.message : String(err);
155
+ return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true };
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: cron
3
+ description: Manage scheduled cron jobs for this agent — list, create, delete, run, and view run history. Use when the user asks to schedule tasks, set up recurring jobs, or check cron status.
4
+ user-invocable: true
5
+ allowed-tools:
6
+ - cron_list
7
+ - cron_create
8
+ - cron_delete
9
+ - cron_run
10
+ - cron_get_runs
11
+ ---
12
+
13
+ # /cron — Cron Job Management
14
+
15
+ Manage scheduled jobs for this agent via the gateway cron API.
16
+
17
+ Arguments passed: `$ARGUMENTS`
18
+
19
+ ---
20
+
21
+ ## Available commands
22
+
23
+ ### No args or "list" — List all cron jobs
24
+
25
+ Use `cron_list` to show all scheduled jobs. Format the output as a table.
26
+
27
+ ### "create" — Create a new cron job
28
+
29
+ Use `cron_create` with the required parameters:
30
+ - `name`: descriptive job name
31
+ - `schedule`: 5-field cron expression (e.g. `0 9 * * *` for daily at 9am)
32
+ - `type`: `command` (shell) or `agent` (Claude prompt)
33
+ - For `command` type: provide `command` parameter
34
+ - For `agent` type: provide `prompt` parameter, optionally `telegram` (chat_id for response)
35
+
36
+ ### "delete <job_id>" — Delete a cron job
37
+
38
+ Use `cron_delete` with `job_id`.
39
+
40
+ ### "run <job_id>" — Run a job immediately
41
+
42
+ Use `cron_run` with `job_id`.
43
+
44
+ ### "runs <job_id>" — View run history
45
+
46
+ Use `cron_get_runs` with `job_id`. Format as a table showing status, duration, and timestamps.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Discord access control — openclaw pattern from allow-list.ts + dm-command-auth.ts.
3
+ * Pure logic, no discord.js dependency.
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { randomBytes } from 'crypto';
9
+ import type { DiscordAccessConfig, DiscordMessageContext, DiscordAccess, DiscordGateResult } from './types';
10
+ export type { DiscordAccessConfig, DiscordAccess } from './types';
11
+
12
+ export type AccessResult = { allowed: boolean; reason?: string };
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // File-based access (new — pairing flow)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export function defaultAccess(): DiscordAccess {
19
+ return {
20
+ dmPolicy: 'pairing',
21
+ allowFrom: [],
22
+ guildAllowlist: [],
23
+ channelAllowlist: [],
24
+ roleAllowlist: [],
25
+ pending: {},
26
+ };
27
+ }
28
+
29
+ export function loadAccess(stateDir: string): DiscordAccess {
30
+ const accessFile = path.join(stateDir, 'access.json');
31
+ try {
32
+ const raw = fs.readFileSync(accessFile, 'utf8');
33
+ const parsed = JSON.parse(raw) as Partial<DiscordAccess>;
34
+ return {
35
+ dmPolicy: parsed.dmPolicy ?? 'pairing',
36
+ allowFrom: parsed.allowFrom ?? [],
37
+ guildAllowlist: parsed.guildAllowlist ?? [],
38
+ channelAllowlist: parsed.channelAllowlist ?? [],
39
+ roleAllowlist: parsed.roleAllowlist ?? [],
40
+ pending: parsed.pending ?? {},
41
+ };
42
+ } catch (err) {
43
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
44
+ // Seed from env vars for backward compat
45
+ return {
46
+ dmPolicy: (process.env.DISCORD_DM_POLICY as DiscordAccess['dmPolicy']) ?? 'pairing',
47
+ allowFrom: process.env.DISCORD_DM_ALLOWLIST
48
+ ? process.env.DISCORD_DM_ALLOWLIST.split(',').filter(Boolean)
49
+ : [],
50
+ guildAllowlist: process.env.DISCORD_GUILD_ALLOWLIST
51
+ ? process.env.DISCORD_GUILD_ALLOWLIST.split(',').filter(Boolean)
52
+ : [],
53
+ channelAllowlist: process.env.DISCORD_CHANNEL_ALLOWLIST
54
+ ? process.env.DISCORD_CHANNEL_ALLOWLIST.split(',').filter(Boolean)
55
+ : [],
56
+ roleAllowlist: process.env.DISCORD_ROLE_ALLOWLIST
57
+ ? process.env.DISCORD_ROLE_ALLOWLIST.split(',').filter(Boolean)
58
+ : [],
59
+ pending: {},
60
+ };
61
+ }
62
+ try {
63
+ fs.renameSync(accessFile, `${accessFile}.corrupt-${Date.now()}`);
64
+ } catch {}
65
+ return defaultAccess();
66
+ }
67
+ }
68
+
69
+ export function saveAccess(stateDir: string, access: DiscordAccess): void {
70
+ fs.mkdirSync(stateDir, { recursive: true });
71
+ const accessFile = path.join(stateDir, 'access.json');
72
+ const tmp = accessFile + '.tmp';
73
+ fs.writeFileSync(tmp, JSON.stringify(access, null, 2) + '\n', { mode: 0o600 });
74
+ fs.renameSync(tmp, accessFile);
75
+ }
76
+
77
+ export function pruneExpired(access: DiscordAccess, now?: number): boolean {
78
+ const ts = now ?? Date.now();
79
+ let changed = false;
80
+ for (const [code, p] of Object.entries(access.pending)) {
81
+ if (p.expiresAt < ts) {
82
+ delete access.pending[code];
83
+ changed = true;
84
+ }
85
+ }
86
+ return changed;
87
+ }
88
+
89
+ export function gate(
90
+ access: DiscordAccess,
91
+ context: DiscordMessageContext,
92
+ saveAccessFn: (a: DiscordAccess) => void,
93
+ generateCode: () => string = () => randomBytes(3).toString('hex'),
94
+ now?: number,
95
+ ): DiscordGateResult {
96
+ const ts = now ?? Date.now();
97
+ const pruned = pruneExpired(access, ts);
98
+ if (pruned) saveAccessFn(access);
99
+
100
+ const { isDM, userId, guildId, channelId } = context;
101
+
102
+ if (!isDM) {
103
+ if (access.guildAllowlist.length > 0 && guildId) {
104
+ if (!access.guildAllowlist.includes(guildId)) return { action: 'drop' };
105
+ }
106
+ if (access.channelAllowlist.length > 0) {
107
+ if (!access.channelAllowlist.includes(channelId)) return { action: 'drop' };
108
+ }
109
+ return { action: 'deliver' };
110
+ }
111
+
112
+ // DM message
113
+ if (access.dmPolicy === 'disabled') return { action: 'drop' };
114
+
115
+ if (access.allowFrom.includes(userId)) return { action: 'deliver' };
116
+ if (access.dmPolicy === 'allowlist') return { action: 'drop' };
117
+
118
+ // pairing mode
119
+ for (const [code, p] of Object.entries(access.pending)) {
120
+ if (p.senderId === userId) {
121
+ if (p.replies >= 2) return { action: 'drop' };
122
+ p.replies++;
123
+ saveAccessFn(access);
124
+ return { action: 'pair', code, isResend: true };
125
+ }
126
+ }
127
+ if (Object.keys(access.pending).length >= 3) return { action: 'drop' };
128
+
129
+ const code = generateCode();
130
+ access.pending[code] = {
131
+ senderId: userId,
132
+ channelId,
133
+ createdAt: ts,
134
+ expiresAt: ts + 60 * 60 * 1000,
135
+ replies: 1,
136
+ };
137
+ saveAccessFn(access);
138
+ return { action: 'pair', code, isResend: false };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Env-var based access (backward compat)
143
+ // ---------------------------------------------------------------------------
144
+
145
+ export function checkAccess(
146
+ config: DiscordAccessConfig,
147
+ context: DiscordMessageContext,
148
+ memberRoles?: string[],
149
+ ): AccessResult {
150
+ if (context.isDM) {
151
+ if (config.dmPolicy === 'disabled') return { allowed: false, reason: 'DM disabled' };
152
+ if (config.dmPolicy === 'allowlist') {
153
+ if (
154
+ !config.dmAllowlist.includes(context.userId) &&
155
+ !config.dmAllowlist.includes('*')
156
+ ) {
157
+ return { allowed: false, reason: 'user not in DM allowlist' };
158
+ }
159
+ }
160
+ return { allowed: true };
161
+ }
162
+
163
+ if (config.guildAllowlist.length && context.guildId) {
164
+ if (!config.guildAllowlist.includes(context.guildId)) {
165
+ return { allowed: false, reason: 'guild not allowed' };
166
+ }
167
+ }
168
+
169
+ if (config.channelAllowlist.length) {
170
+ if (!config.channelAllowlist.includes(context.channelId)) {
171
+ return { allowed: false, reason: 'channel not allowed' };
172
+ }
173
+ }
174
+
175
+ if (config.roleAllowlist.length && memberRoles) {
176
+ const hasRole = memberRoles.some(r => config.roleAllowlist.includes(r));
177
+ if (!hasRole) return { allowed: false, reason: 'missing required role' };
178
+ }
179
+
180
+ return { allowed: true };
181
+ }
182
+
183
+ export function buildAccessConfig(env: NodeJS.ProcessEnv = process.env): DiscordAccessConfig {
184
+ return {
185
+ dmPolicy: (env.DISCORD_DM_POLICY as DiscordAccessConfig['dmPolicy']) ?? 'disabled',
186
+ dmAllowlist: env.DISCORD_DM_ALLOWLIST ? env.DISCORD_DM_ALLOWLIST.split(',').filter(Boolean) : [],
187
+ guildAllowlist: env.DISCORD_GUILD_ALLOWLIST
188
+ ? env.DISCORD_GUILD_ALLOWLIST.split(',').filter(Boolean)
189
+ : [],
190
+ channelAllowlist: env.DISCORD_CHANNEL_ALLOWLIST
191
+ ? env.DISCORD_CHANNEL_ALLOWLIST.split(',').filter(Boolean)
192
+ : [],
193
+ roleAllowlist: env.DISCORD_ROLE_ALLOWLIST
194
+ ? env.DISCORD_ROLE_ALLOWLIST.split(',').filter(Boolean)
195
+ : [],
196
+ };
197
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Discord.js client factory.
3
+ * Dynamic import keeps discord.js out of the module graph at load time.
4
+ */
5
+
6
+ export async function createDiscordClient(token: string): Promise<any> {
7
+ // @ts-ignore — discord.js in mcp/node_modules
8
+ const { Client, GatewayIntentBits, Partials } = await import('discord.js');
9
+
10
+ const client = new Client({
11
+ intents: [
12
+ GatewayIntentBits.Guilds,
13
+ GatewayIntentBits.GuildMessages,
14
+ GatewayIntentBits.DirectMessages,
15
+ GatewayIntentBits.MessageContent,
16
+ GatewayIntentBits.GuildMessageReactions,
17
+ ],
18
+ partials: [
19
+ Partials.Channel,
20
+ Partials.Message,
21
+ ],
22
+ });
23
+
24
+ await new Promise<void>((resolve, reject) => {
25
+ client.once('ready', () => resolve());
26
+ client.once('error', reject);
27
+ client.login(token).catch(reject);
28
+ });
29
+
30
+ return client;
31
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Discord slash command definitions — openclaw pattern from native-command.ts.
3
+ * SLASH_COMMANDS is plain JSON; discord.js builders only used in registerCommands().
4
+ */
5
+
6
+ import type { SlashCommandDef } from './types';
7
+
8
+ export const SLASH_COMMANDS: SlashCommandDef[] = [
9
+ {
10
+ name: 'ask',
11
+ description: 'Ask the agent a question',
12
+ options: [{ name: 'question', description: 'Your question', required: true, type: 'STRING' }],
13
+ },
14
+ { name: 'session', description: 'Show current session info' },
15
+ {
16
+ name: 'new',
17
+ description: 'Start a new conversation',
18
+ options: [{ name: 'name', description: 'Session name', required: false, type: 'STRING' }],
19
+ },
20
+ {
21
+ name: 'model',
22
+ description: 'Show or switch AI model',
23
+ options: [{ name: 'name', description: 'Model name', required: false, type: 'STRING' }],
24
+ },
25
+ ];
26
+
27
+ export async function registerCommands(
28
+ client: any,
29
+ token: string,
30
+ ): Promise<void> {
31
+ // @ts-ignore — discord.js in mcp/node_modules
32
+ const { REST, Routes, SlashCommandBuilder } = await import('discord.js');
33
+ const rest = new REST().setToken(token);
34
+
35
+ const bodies = SLASH_COMMANDS.map(cmd => {
36
+ const builder = new SlashCommandBuilder()
37
+ .setName(cmd.name)
38
+ .setDescription(cmd.description);
39
+ for (const opt of cmd.options ?? []) {
40
+ builder.addStringOption((o: any) =>
41
+ o.setName(opt.name).setDescription(opt.description).setRequired(opt.required),
42
+ );
43
+ }
44
+ return builder.toJSON();
45
+ });
46
+
47
+ await rest.put(Routes.applicationCommands(client.user!.id), { body: bodies });
48
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Discord inbound message handler — openclaw pattern from message-handler.ts.
3
+ * Preflight validation → context building → handler dispatch.
4
+ */
5
+
6
+ import type { InboundMessage, InboundMessageHandler } from '../../types';
7
+ import type { DiscordConfig, DiscordMessage, DiscordMessageContext } from './types';
8
+ import type { DiscordAccessConfig } from './access';
9
+ import { checkAccess } from './access';
10
+
11
+ export function createMessageHandler(
12
+ agentId: string,
13
+ handler: InboundMessageHandler,
14
+ config: DiscordConfig,
15
+ accessConfig: DiscordAccessConfig,
16
+ ) {
17
+ return async (message: DiscordMessage): Promise<void> => {
18
+ if (message.author.bot) return;
19
+ if (message.system) return;
20
+
21
+ const isDM = !message.guild;
22
+ const isThread = message.channel.isThread();
23
+
24
+ const context: DiscordMessageContext = {
25
+ guildId: message.guildId,
26
+ channelId: message.channelId,
27
+ threadId: isThread ? message.channelId : null,
28
+ userId: message.author.id,
29
+ username: message.author.username,
30
+ messageId: message.id,
31
+ isDM,
32
+ isThread,
33
+ };
34
+
35
+ const result = checkAccess(accessConfig, context);
36
+ if (!result.allowed) return;
37
+
38
+ const inbound: InboundMessage = {
39
+ channel: 'discord',
40
+ accountId: message.client.user?.id ?? 'discord',
41
+ senderId: message.author.id,
42
+ chatId: isThread ? message.channelId : message.channelId,
43
+ chatType: isDM ? 'direct' : 'group',
44
+ text: message.content,
45
+ messageId: message.id,
46
+ threadId: isThread ? message.channelId : undefined,
47
+ attachmentFileId: message.attachments.first()?.url,
48
+ ts: message.createdTimestamp,
49
+ };
50
+
51
+ await handler(inbound);
52
+ };
53
+ }