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.
- package/SPEC.md +240 -0
- package/dist/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +44 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -0
- package/dist/handlers/mention.d.ts +10 -0
- package/dist/handlers/mention.d.ts.map +1 -0
- package/dist/handlers/mention.js +96 -0
- package/dist/handlers/mention.js.map +1 -0
- package/dist/handlers/thread.d.ts +8 -0
- package/dist/handlers/thread.d.ts.map +1 -0
- package/dist/handlers/thread.js +50 -0
- package/dist/handlers/thread.js.map +1 -0
- package/dist/services/claude.d.ts +27 -0
- package/dist/services/claude.d.ts.map +1 -0
- package/dist/services/claude.js +192 -0
- package/dist/services/claude.js.map +1 -0
- package/dist/services/git.d.ts +15 -0
- package/dist/services/git.d.ts.map +1 -0
- package/dist/services/git.js +81 -0
- package/dist/services/git.js.map +1 -0
- package/dist/services/permissions.d.ts +12 -0
- package/dist/services/permissions.d.ts.map +1 -0
- package/dist/services/permissions.js +98 -0
- package/dist/services/permissions.js.map +1 -0
- package/dist/services/slack-messenger.d.ts +11 -0
- package/dist/services/slack-messenger.d.ts.map +1 -0
- package/dist/services/slack-messenger.js +73 -0
- package/dist/services/slack-messenger.js.map +1 -0
- package/dist/utils/parse.d.ts +21 -0
- package/dist/utils/parse.d.ts.map +1 -0
- package/dist/utils/parse.js +51 -0
- package/dist/utils/parse.js.map +1 -0
- package/package.json +22 -0
- package/src/app.ts +54 -0
- package/src/cli.ts +47 -0
- package/src/handlers/mention.ts +119 -0
- package/src/handlers/thread.ts +61 -0
- package/src/services/claude.ts +280 -0
- package/src/services/git.ts +98 -0
- package/src/services/permissions.ts +131 -0
- package/src/services/slack-messenger.ts +102 -0
- package/src/utils/parse.ts +66 -0
- 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
|
+
}
|