eniac-slack 0.0.2

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 (48) hide show
  1. package/SPEC.md +240 -0
  2. package/dist/app.d.ts +8 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +44 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +39 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/handlers/mention.d.ts +10 -0
  11. package/dist/handlers/mention.d.ts.map +1 -0
  12. package/dist/handlers/mention.js +96 -0
  13. package/dist/handlers/mention.js.map +1 -0
  14. package/dist/handlers/thread.d.ts +8 -0
  15. package/dist/handlers/thread.d.ts.map +1 -0
  16. package/dist/handlers/thread.js +50 -0
  17. package/dist/handlers/thread.js.map +1 -0
  18. package/dist/services/claude.d.ts +27 -0
  19. package/dist/services/claude.d.ts.map +1 -0
  20. package/dist/services/claude.js +192 -0
  21. package/dist/services/claude.js.map +1 -0
  22. package/dist/services/git.d.ts +15 -0
  23. package/dist/services/git.d.ts.map +1 -0
  24. package/dist/services/git.js +81 -0
  25. package/dist/services/git.js.map +1 -0
  26. package/dist/services/permissions.d.ts +12 -0
  27. package/dist/services/permissions.d.ts.map +1 -0
  28. package/dist/services/permissions.js +98 -0
  29. package/dist/services/permissions.js.map +1 -0
  30. package/dist/services/slack-messenger.d.ts +11 -0
  31. package/dist/services/slack-messenger.d.ts.map +1 -0
  32. package/dist/services/slack-messenger.js +73 -0
  33. package/dist/services/slack-messenger.js.map +1 -0
  34. package/dist/utils/parse.d.ts +21 -0
  35. package/dist/utils/parse.d.ts.map +1 -0
  36. package/dist/utils/parse.js +51 -0
  37. package/dist/utils/parse.js.map +1 -0
  38. package/package.json +22 -0
  39. package/src/app.ts +54 -0
  40. package/src/cli.ts +47 -0
  41. package/src/handlers/mention.ts +119 -0
  42. package/src/handlers/thread.ts +61 -0
  43. package/src/services/claude.ts +280 -0
  44. package/src/services/git.ts +98 -0
  45. package/src/services/permissions.ts +131 -0
  46. package/src/services/slack-messenger.ts +102 -0
  47. package/src/utils/parse.ts +66 -0
  48. package/tsconfig.json +8 -0
@@ -0,0 +1,131 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+
3
+ interface PendingPermission {
4
+ resolve: (granted: boolean) => void;
5
+ channel: string;
6
+ threadTs: string;
7
+ messageTs?: string;
8
+ authorUserId: string;
9
+ }
10
+
11
+ const pending = new Map<string, PendingPermission>();
12
+
13
+ /**
14
+ * Register a pending permission request and post Slack buttons.
15
+ * Returns a Promise that resolves when the user clicks Approve or Deny.
16
+ */
17
+ export async function requestPermission(
18
+ client: WebClient,
19
+ channel: string,
20
+ threadTs: string,
21
+ permissionId: string,
22
+ toolName: string,
23
+ description: string,
24
+ authorUserId?: string
25
+ ): Promise<boolean> {
26
+ console.log(`[perm] posting buttons: tool=${toolName}, permId=${permissionId}, author=${authorUserId}`);
27
+
28
+ const result = await client.chat.postMessage({
29
+ channel,
30
+ thread_ts: threadTs,
31
+ text: `Permission request: ${toolName}`,
32
+ blocks: [
33
+ {
34
+ type: "section",
35
+ text: {
36
+ type: "mrkdwn",
37
+ text: `:lock: *Permission Required*\n\n*Tool:* \`${toolName}\`\n${description}`,
38
+ },
39
+ },
40
+ {
41
+ type: "actions",
42
+ block_id: `perm_${permissionId}`,
43
+ elements: [
44
+ {
45
+ type: "button",
46
+ text: { type: "plain_text", text: "Approve" },
47
+ style: "primary",
48
+ action_id: "approve_permission",
49
+ value: permissionId,
50
+ },
51
+ {
52
+ type: "button",
53
+ text: { type: "plain_text", text: "Deny" },
54
+ style: "danger",
55
+ action_id: "deny_permission",
56
+ value: permissionId,
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ });
62
+
63
+ console.log(`[perm] buttons posted: messageTs=${result.ts}`);
64
+
65
+ return new Promise<boolean>((resolve) => {
66
+ pending.set(permissionId, {
67
+ resolve,
68
+ channel,
69
+ threadTs,
70
+ messageTs: result.ts,
71
+ authorUserId: authorUserId ?? "",
72
+ });
73
+ console.log(`[perm] pending entry set: permId=${permissionId}, pending.size=${pending.size}`);
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Resolve a pending permission (called from Slack action handler).
79
+ * Only the original thread author can approve/deny.
80
+ */
81
+ export async function resolvePermission(
82
+ client: WebClient,
83
+ permissionId: string,
84
+ granted: boolean,
85
+ clickedUserId: string
86
+ ): Promise<void> {
87
+ console.log(`[perm] resolvePermission called: permId=${permissionId}, granted=${granted}, clickedBy=${clickedUserId}`);
88
+ console.log(`[perm] pending keys: [${[...pending.keys()].join(", ")}]`);
89
+
90
+ const entry = pending.get(permissionId);
91
+ if (!entry) {
92
+ console.log(`[perm] ERROR: no pending entry found for ${permissionId}`);
93
+ return;
94
+ }
95
+
96
+ console.log(`[perm] entry found: authorUserId=${entry.authorUserId}, clickedUserId=${clickedUserId}`);
97
+
98
+ // Only the thread author can approve/deny
99
+ if (entry.authorUserId && clickedUserId !== entry.authorUserId) {
100
+ console.log(`[perm] REJECTED: author mismatch! expected=${entry.authorUserId}, got=${clickedUserId}`);
101
+ return;
102
+ }
103
+
104
+ pending.delete(permissionId);
105
+
106
+ // Update the button message to show the decision
107
+ if (entry.messageTs) {
108
+ const status = granted
109
+ ? `:white_check_mark: *Approved* by <@${clickedUserId}>`
110
+ : `:no_entry_sign: *Denied* by <@${clickedUserId}>`;
111
+
112
+ try {
113
+ await client.chat.update({
114
+ channel: entry.channel,
115
+ ts: entry.messageTs,
116
+ text: status,
117
+ blocks: [
118
+ {
119
+ type: "section",
120
+ text: { type: "mrkdwn", text: status },
121
+ },
122
+ ],
123
+ });
124
+ } catch {
125
+ // best effort update
126
+ }
127
+ }
128
+
129
+ console.log(`[perm] resolving promise with granted=${granted}`);
130
+ entry.resolve(granted);
131
+ }
@@ -0,0 +1,102 @@
1
+ import type { WebClient } from "@slack/web-api";
2
+ import type { ChatEvent } from "./claude.js";
3
+
4
+ const THROTTLE_MS = 500;
5
+
6
+ /**
7
+ * Post a streaming reply in a Slack thread, handling both text and tool events.
8
+ *
9
+ * - Text chunks are accumulated and the message is updated in real-time.
10
+ * - Tool use / tool result events are logged but don't affect the main message.
11
+ * - Permission requests are handled by claude.ts (via permissions.ts).
12
+ */
13
+ export async function postStreamingReply(
14
+ client: WebClient,
15
+ channel: string,
16
+ threadTs: string,
17
+ eventStream: AsyncGenerator<ChatEvent, void, unknown>
18
+ ): Promise<string> {
19
+ const initial = await client.chat.postMessage({
20
+ channel,
21
+ thread_ts: threadTs,
22
+ text: ":hourglass_flowing_sand: Thinking...",
23
+ });
24
+
25
+ const messageTs = initial.ts;
26
+ if (!messageTs) {
27
+ throw new Error("Failed to post initial message — no ts returned");
28
+ }
29
+
30
+ let accumulated = "";
31
+ let lastUpdateTime = 0;
32
+ let pendingUpdate = false;
33
+
34
+ // Trim accumulated text to the last word boundary to avoid
35
+ // partial Korean characters being interpreted as punycode URLs by Slack
36
+ const safeSlice = (text: string): string => {
37
+ // Find last whitespace or newline
38
+ const lastBreak = Math.max(
39
+ text.lastIndexOf(" "),
40
+ text.lastIndexOf("\n"),
41
+ text.lastIndexOf("\t")
42
+ );
43
+ // If break is near the end (within 20 chars), use it; otherwise send everything
44
+ if (lastBreak > 0 && text.length - lastBreak <= 20) {
45
+ return text.slice(0, lastBreak + 1);
46
+ }
47
+ return text;
48
+ };
49
+
50
+ const doUpdate = async (text: string, isFinal: boolean) => {
51
+ const now = Date.now();
52
+ const elapsed = now - lastUpdateTime;
53
+
54
+ if (!isFinal && elapsed < THROTTLE_MS) {
55
+ pendingUpdate = true;
56
+ return;
57
+ }
58
+
59
+ pendingUpdate = false;
60
+ lastUpdateTime = Date.now();
61
+
62
+ // For intermediate updates, trim to word boundary to prevent garbled display
63
+ const displayText = isFinal ? text : safeSlice(text);
64
+
65
+ try {
66
+ await client.chat.update({
67
+ channel,
68
+ ts: messageTs,
69
+ text: displayText || ":hourglass_flowing_sand: Thinking...",
70
+ });
71
+ } catch (error) {
72
+ console.warn(
73
+ "[slack-messenger] Failed to update message:",
74
+ error instanceof Error ? error.message : error
75
+ );
76
+ }
77
+ };
78
+
79
+ for await (const event of eventStream) {
80
+ switch (event.type) {
81
+ case "text":
82
+ accumulated += event.content;
83
+ await doUpdate(accumulated, false);
84
+ break;
85
+
86
+ case "error":
87
+ accumulated += `\n\n:warning: ${event.message}`;
88
+ await doUpdate(accumulated, true);
89
+ break;
90
+ }
91
+ }
92
+
93
+ // Final flush
94
+ if (pendingUpdate || accumulated) {
95
+ await doUpdate(
96
+ accumulated || ":warning: No response generated.",
97
+ true
98
+ );
99
+ }
100
+
101
+ return messageTs;
102
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Remove the bot mention (`<@BOTID>`) from message text.
3
+ */
4
+ export function removeMention(text: string): string {
5
+ return text.replace(/<@[A-Z0-9]+>/g, "").trim();
6
+ }
7
+
8
+ export interface GithubRepo {
9
+ owner: string;
10
+ repo: string;
11
+ url: string;
12
+ }
13
+
14
+ /**
15
+ * Extract a GitHub repository identifier from text.
16
+ *
17
+ * Supported formats:
18
+ * - `https://github.com/owner/repo`
19
+ * - `github.com/owner/repo`
20
+ * - `owner/repo` (only when it looks like a valid GitHub identifier)
21
+ *
22
+ * Returns `null` if no match is found.
23
+ */
24
+ export function extractGithubRepo(text: string): GithubRepo | null {
25
+ // Full URL: https://github.com/owner/repo or github.com/owner/repo
26
+ const urlPattern =
27
+ /(?:https?:\/\/)?github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/;
28
+ const urlMatch = text.match(urlPattern);
29
+
30
+ if (urlMatch) {
31
+ const owner = urlMatch[1]!;
32
+ const repo = urlMatch[2]!.replace(/\.git$/, "");
33
+ return {
34
+ owner,
35
+ repo,
36
+ url: `https://github.com/${owner}/${repo}.git`,
37
+ };
38
+ }
39
+
40
+ // Short form: owner/repo — must be a standalone token with valid GitHub naming
41
+ const shortPattern = /\b([a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?)\/([a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?)\b/;
42
+ const shortMatch = text.match(shortPattern);
43
+
44
+ if (shortMatch) {
45
+ const owner = shortMatch[1]!;
46
+ const repo = shortMatch[2]!;
47
+
48
+ // Reject things that look like file paths or generic patterns
49
+ if (
50
+ owner.includes("..") ||
51
+ repo.includes("..") ||
52
+ owner.startsWith(".") ||
53
+ repo.startsWith(".")
54
+ ) {
55
+ return null;
56
+ }
57
+
58
+ return {
59
+ owner,
60
+ repo,
61
+ url: `https://github.com/${owner}/${repo}.git`,
62
+ };
63
+ }
64
+
65
+ return null;
66
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }