@pollit/twin-dev-bot 0.0.1
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/LICENSE +661 -0
- package/README.md +415 -0
- package/bin/twindevbot.js +22 -0
- package/dist/action-payload-store.d.ts +22 -0
- package/dist/action-payload-store.js +54 -0
- package/dist/active-runners.d.ts +44 -0
- package/dist/active-runners.js +114 -0
- package/dist/channel-store.d.ts +16 -0
- package/dist/channel-store.js +91 -0
- package/dist/claude/active-runners.d.ts +44 -0
- package/dist/claude/active-runners.js +114 -0
- package/dist/claude/claude-runner.d.ts +57 -0
- package/dist/claude/claude-runner.js +210 -0
- package/dist/claude/session-manager.d.ts +62 -0
- package/dist/claude/session-manager.js +247 -0
- package/dist/claude-runner.d.ts +57 -0
- package/dist/claude-runner.js +210 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +271 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +49 -0
- package/dist/conversation-store.d.ts +53 -0
- package/dist/conversation-store.js +173 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +49 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +110 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.js +18 -0
- package/dist/core/platform.d.ts +18 -0
- package/dist/core/platform.js +33 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +14 -0
- package/dist/daemon/macos.d.ts +8 -0
- package/dist/daemon/macos.js +150 -0
- package/dist/daemon/types.d.ts +9 -0
- package/dist/daemon/types.js +1 -0
- package/dist/daemon/windows.d.ts +8 -0
- package/dist/daemon/windows.js +137 -0
- package/dist/handlers/claude-command.d.ts +2 -0
- package/dist/handlers/claude-command.js +634 -0
- package/dist/handlers/claude-runner-setup.d.ts +16 -0
- package/dist/handlers/claude-runner-setup.js +445 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +3 -0
- package/dist/handlers/init-handlers.d.ts +2 -0
- package/dist/handlers/init-handlers.js +189 -0
- package/dist/handlers/question-handlers.d.ts +2 -0
- package/dist/handlers/question-handlers.js +835 -0
- package/dist/i18n/en.d.ts +150 -0
- package/dist/i18n/en.js +163 -0
- package/dist/i18n/index.d.ts +20 -0
- package/dist/i18n/index.js +31 -0
- package/dist/i18n/ko.d.ts +1 -0
- package/dist/i18n/ko.js +141 -0
- package/dist/logger.d.ts +34 -0
- package/dist/logger.js +110 -0
- package/dist/multi-select-state.d.ts +58 -0
- package/dist/multi-select-state.js +151 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +18 -0
- package/dist/pending-questions.d.ts +53 -0
- package/dist/pending-questions.js +139 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +33 -0
- package/dist/progress-tracker.d.ts +47 -0
- package/dist/progress-tracker.js +218 -0
- package/dist/question-blocks.d.ts +27 -0
- package/dist/question-blocks.js +235 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +83 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +247 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +132 -0
- package/dist/slack/progress-tracker.d.ts +47 -0
- package/dist/slack/progress-tracker.js +218 -0
- package/dist/slack/question-blocks.d.ts +27 -0
- package/dist/slack/question-blocks.js +235 -0
- package/dist/stores/action-payload-store.d.ts +22 -0
- package/dist/stores/action-payload-store.js +54 -0
- package/dist/stores/channel-store.d.ts +16 -0
- package/dist/stores/channel-store.js +91 -0
- package/dist/stores/multi-select-state.d.ts +58 -0
- package/dist/stores/multi-select-state.js +151 -0
- package/dist/stores/pending-questions.d.ts +53 -0
- package/dist/stores/pending-questions.js +139 -0
- package/dist/stores/workspace-store.d.ts +27 -0
- package/dist/stores/workspace-store.js +160 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +292 -0
- package/dist/types/claude-stream.d.ts +116 -0
- package/dist/types/claude-stream.js +3 -0
- package/dist/types/conversation.d.ts +16 -0
- package/dist/types/conversation.js +4 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/slack.d.ts +51 -0
- package/dist/types/slack.js +1 -0
- package/dist/utils/display-width.d.ts +8 -0
- package/dist/utils/display-width.js +33 -0
- package/dist/utils/safe-async.d.ts +6 -0
- package/dist/utils/safe-async.js +14 -0
- package/dist/utils/slack-message.d.ts +73 -0
- package/dist/utils/slack-message.js +220 -0
- package/dist/utils/slack-rate-limit.d.ts +5 -0
- package/dist/utils/slack-rate-limit.js +49 -0
- package/dist/workspace-store.d.ts +27 -0
- package/dist/workspace-store.js +160 -0
- package/package.json +51 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export interface ClaudeInitEvent {
|
|
2
|
+
type: "system";
|
|
3
|
+
subtype: "init";
|
|
4
|
+
cwd: string;
|
|
5
|
+
session_id: string;
|
|
6
|
+
tools: string[];
|
|
7
|
+
mcp_servers: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
status: string;
|
|
10
|
+
}>;
|
|
11
|
+
model: string;
|
|
12
|
+
permissionMode: string;
|
|
13
|
+
slash_commands: string[];
|
|
14
|
+
apiKeySource: string;
|
|
15
|
+
claude_code_version: string;
|
|
16
|
+
output_style: string;
|
|
17
|
+
agents: string[];
|
|
18
|
+
skills: string[];
|
|
19
|
+
plugins: string[];
|
|
20
|
+
uuid: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ClaudeAssistantMessage {
|
|
23
|
+
type: "assistant";
|
|
24
|
+
message: {
|
|
25
|
+
model: string;
|
|
26
|
+
id: string;
|
|
27
|
+
type: "message";
|
|
28
|
+
role: "assistant";
|
|
29
|
+
content: ClaudeContentBlock[];
|
|
30
|
+
stop_reason: string | null;
|
|
31
|
+
stop_sequence: string | null;
|
|
32
|
+
usage: ClaudeUsage;
|
|
33
|
+
context_management: unknown | null;
|
|
34
|
+
};
|
|
35
|
+
parent_tool_use_id: string | null;
|
|
36
|
+
session_id: string;
|
|
37
|
+
uuid: string;
|
|
38
|
+
}
|
|
39
|
+
/** assistant 메시지의 content block */
|
|
40
|
+
export interface ClaudeContentBlock {
|
|
41
|
+
type: "text" | "tool_use";
|
|
42
|
+
text?: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
input?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
/** tool result를 담는 user 메시지 (도구 실행 후 Claude에게 결과를 전달) */
|
|
48
|
+
export interface ClaudeUserMessage {
|
|
49
|
+
type: "user";
|
|
50
|
+
message: {
|
|
51
|
+
role: "user";
|
|
52
|
+
content: Array<{
|
|
53
|
+
tool_use_id: string;
|
|
54
|
+
type: "tool_result";
|
|
55
|
+
content: string;
|
|
56
|
+
}>;
|
|
57
|
+
};
|
|
58
|
+
parent_tool_use_id: string | null;
|
|
59
|
+
session_id: string;
|
|
60
|
+
uuid: string;
|
|
61
|
+
tool_use_result: {
|
|
62
|
+
type: string;
|
|
63
|
+
file?: {
|
|
64
|
+
filePath: string;
|
|
65
|
+
content: string;
|
|
66
|
+
numLines: number;
|
|
67
|
+
startLine: number;
|
|
68
|
+
totalLines: number;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export interface ClaudeResultEvent {
|
|
73
|
+
type: "result";
|
|
74
|
+
subtype: "success" | "error_max_turns" | "error_during_execution" | "error_max_budget_usd";
|
|
75
|
+
is_error: boolean;
|
|
76
|
+
duration_ms: number;
|
|
77
|
+
duration_api_ms: number;
|
|
78
|
+
num_turns: number;
|
|
79
|
+
result: string;
|
|
80
|
+
session_id: string;
|
|
81
|
+
total_cost_usd: number;
|
|
82
|
+
usage: ClaudeUsage;
|
|
83
|
+
modelUsage: Record<string, ClaudeModelUsage>;
|
|
84
|
+
permission_denials: Array<{
|
|
85
|
+
tool_name: string;
|
|
86
|
+
tool_use_id: string;
|
|
87
|
+
tool_input: unknown;
|
|
88
|
+
}>;
|
|
89
|
+
uuid: string;
|
|
90
|
+
}
|
|
91
|
+
export interface ClaudeUsage {
|
|
92
|
+
input_tokens: number;
|
|
93
|
+
output_tokens: number;
|
|
94
|
+
cache_creation_input_tokens?: number;
|
|
95
|
+
cache_read_input_tokens?: number;
|
|
96
|
+
cache_creation?: {
|
|
97
|
+
ephemeral_5m_input_tokens: number;
|
|
98
|
+
ephemeral_1h_input_tokens: number;
|
|
99
|
+
};
|
|
100
|
+
server_tool_use?: {
|
|
101
|
+
web_search_requests: number;
|
|
102
|
+
web_fetch_requests: number;
|
|
103
|
+
};
|
|
104
|
+
service_tier?: string;
|
|
105
|
+
}
|
|
106
|
+
export interface ClaudeModelUsage {
|
|
107
|
+
inputTokens: number;
|
|
108
|
+
outputTokens: number;
|
|
109
|
+
cacheReadInputTokens: number;
|
|
110
|
+
cacheCreationInputTokens: number;
|
|
111
|
+
webSearchRequests: number;
|
|
112
|
+
costUSD: number;
|
|
113
|
+
contextWindow: number;
|
|
114
|
+
maxOutputTokens: number;
|
|
115
|
+
}
|
|
116
|
+
export type ClaudeStreamEvent = ClaudeInitEvent | ClaudeAssistantMessage | ClaudeUserMessage | ClaudeResultEvent;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 질문 관련 타입 정의
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 질문 (AskUserQuestion의 questions 배열 항목)
|
|
6
|
+
*/
|
|
7
|
+
export interface Question {
|
|
8
|
+
question: string;
|
|
9
|
+
header?: string;
|
|
10
|
+
options: QuestionOption[];
|
|
11
|
+
multiSelect?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface QuestionOption {
|
|
14
|
+
label: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface SelectedOptionValue {
|
|
2
|
+
questionIndex: number;
|
|
3
|
+
optionIndex: number;
|
|
4
|
+
label: string;
|
|
5
|
+
isMultiSelect: boolean;
|
|
6
|
+
projectName: string;
|
|
7
|
+
messageId: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ToggleOptionValue {
|
|
10
|
+
questionIndex: number;
|
|
11
|
+
optionIndex: number;
|
|
12
|
+
label: string;
|
|
13
|
+
projectName: string;
|
|
14
|
+
messageId: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SubmitMultiSelectValue {
|
|
17
|
+
questionIndex: number;
|
|
18
|
+
projectName: string;
|
|
19
|
+
messageId: string;
|
|
20
|
+
}
|
|
21
|
+
export interface TextInputButtonValue {
|
|
22
|
+
questionIndex: number;
|
|
23
|
+
type: "text_input";
|
|
24
|
+
projectName: string;
|
|
25
|
+
messageId: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TextInputModalMetadata {
|
|
28
|
+
requestId: string;
|
|
29
|
+
questionIndex: number;
|
|
30
|
+
channelId: string;
|
|
31
|
+
messageTs: string;
|
|
32
|
+
threadTs: string;
|
|
33
|
+
}
|
|
34
|
+
export interface AutopilotInterruptValue {
|
|
35
|
+
threadTs: string;
|
|
36
|
+
channelId: string;
|
|
37
|
+
projectName: string;
|
|
38
|
+
userMessageTs?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface StoredQuestionPayload {
|
|
41
|
+
questionText: string;
|
|
42
|
+
header?: string;
|
|
43
|
+
optionLabels?: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface InitDirSelectValue {
|
|
46
|
+
dirName: string;
|
|
47
|
+
}
|
|
48
|
+
export interface InitCustomDirModalMetadata {
|
|
49
|
+
channelId: string;
|
|
50
|
+
originalMessageTs: string;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 문자열의 "표시 너비"를 대략 계산한다.
|
|
3
|
+
*
|
|
4
|
+
* - CJK/전각/이모지 계열은 2칸으로, variation selector는 0칸으로 본다.
|
|
5
|
+
* - 그 외 문자는 1칸으로 계산한다.
|
|
6
|
+
* - CLI 박스/정렬용 간이 계산이므로 완전한 유니코드 폭 구현은 아니다.
|
|
7
|
+
*/
|
|
8
|
+
export function getDisplayWidth(str) {
|
|
9
|
+
let width = 0;
|
|
10
|
+
for (const char of str) {
|
|
11
|
+
const code = char.codePointAt(0);
|
|
12
|
+
// Variation selectors are zero-width
|
|
13
|
+
if (code >= 0xfe00 && code <= 0xfe0f) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if ((code >= 0x1100 && code <= 0x115f) ||
|
|
17
|
+
(code >= 0x2e80 && code <= 0x9fff) ||
|
|
18
|
+
(code >= 0xac00 && code <= 0xd7af) ||
|
|
19
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
20
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
21
|
+
(code >= 0xff01 && code <= 0xff60) ||
|
|
22
|
+
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth forms (currency, arrows)
|
|
23
|
+
(code >= 0x3300 && code <= 0x33ff) || // CJK Compatibility
|
|
24
|
+
(code >= 0x1f300 && code <= 0x1f9ff) || // Emoji ranges (commonly width 2)
|
|
25
|
+
(code >= 0x20000 && code <= 0x2fa1f)) {
|
|
26
|
+
width += 2;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
width += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return width;
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* async EventEmitter 콜백을 try-catch로 감싸는 래퍼.
|
|
3
|
+
* EventEmitter는 async 콜백의 rejection을 자동 처리하지 않으므로,
|
|
4
|
+
* 이 래퍼를 사용하여 unhandledRejection을 방지한다.
|
|
5
|
+
*/
|
|
6
|
+
export declare function safeAsync<T extends unknown[]>(handler: (...args: T) => Promise<void>, context: string): (...args: T) => void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createLogger } from "../core/logger.js";
|
|
2
|
+
const log = createLogger("safe-async");
|
|
3
|
+
/**
|
|
4
|
+
* async EventEmitter 콜백을 try-catch로 감싸는 래퍼.
|
|
5
|
+
* EventEmitter는 async 콜백의 rejection을 자동 처리하지 않으므로,
|
|
6
|
+
* 이 래퍼를 사용하여 unhandledRejection을 방지한다.
|
|
7
|
+
*/
|
|
8
|
+
export function safeAsync(handler, context) {
|
|
9
|
+
return (...args) => {
|
|
10
|
+
handler(...args).catch((error) => {
|
|
11
|
+
log.error(`Error in async handler [${context}]`, error);
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { WebClient } from "@slack/web-api";
|
|
2
|
+
import type { Question } from "../types/conversation.js";
|
|
3
|
+
/**
|
|
4
|
+
* Slack 메시지 텍스트 최대 길이.
|
|
5
|
+
* Slack은 40,000자에서 메시지를 잘라내므로 약간의 여유를 두고 39,000자로 설정.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SLACK_MAX_TEXT_LENGTH = 39000;
|
|
8
|
+
/**
|
|
9
|
+
* 텍스트에서 열린(닫히지 않은) 코드 펜스가 있는지 확인.
|
|
10
|
+
* 열린 코드 펜스가 있으면 해당 펜스의 언어 식별자를 반환, 없으면 null.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getUnclosedCodeFence(text: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* 긴 텍스트를 Slack 메시지 길이 제한에 맞게 분할.
|
|
15
|
+
* 가능하면 단락 경계(\n\n), 줄바꿈(\n) 순으로 자연스러운 위치에서 분할.
|
|
16
|
+
* 코드 블록(```)이 분할로 깨지면 자동으로 닫고 다음 청크에서 다시 열어줌.
|
|
17
|
+
*/
|
|
18
|
+
export declare function splitText(text: string, maxLength?: number): string[];
|
|
19
|
+
export type PostMessageResult = {
|
|
20
|
+
success: true;
|
|
21
|
+
ts: string | undefined;
|
|
22
|
+
} | {
|
|
23
|
+
success: false;
|
|
24
|
+
error: unknown;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* 스레드에 메시지 전송.
|
|
28
|
+
* Slack 메시지 길이 제한(40,000자)을 초과하면 자동으로 분할 전송.
|
|
29
|
+
*/
|
|
30
|
+
export declare function postThreadMessage(client: WebClient, channelId: string, text: string, threadTs: string): Promise<PostMessageResult>;
|
|
31
|
+
/**
|
|
32
|
+
* 채널에 메시지 전송 (스레드 없음)
|
|
33
|
+
*/
|
|
34
|
+
export declare function postChannelMessage(client: WebClient, channelId: string, text: string): Promise<PostMessageResult>;
|
|
35
|
+
interface UpdateSlackMessageOptions {
|
|
36
|
+
client: WebClient;
|
|
37
|
+
channelId: string;
|
|
38
|
+
ts: string;
|
|
39
|
+
projectName: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
question: Question;
|
|
42
|
+
/** 선택된 답변 (완료 시) */
|
|
43
|
+
selectedAnswer?: string;
|
|
44
|
+
isSubmitted: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Slack 메시지 업데이트 (버튼 클릭 후 상태 반영)
|
|
48
|
+
*/
|
|
49
|
+
export declare function updateSlackMessage(options: UpdateSlackMessageOptions): Promise<void>;
|
|
50
|
+
interface UpdateMultiSelectOptions {
|
|
51
|
+
client: WebClient;
|
|
52
|
+
channelId: string;
|
|
53
|
+
ts: string;
|
|
54
|
+
projectName: string;
|
|
55
|
+
messageId: string;
|
|
56
|
+
selectedOptionIndexes: number[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 복수 선택 토글 후 Slack 메시지 업데이트
|
|
60
|
+
* multi-select-state에서 옵션 정보를 가져와서 blocks 재생성
|
|
61
|
+
*/
|
|
62
|
+
export declare function updateSlackMessageWithMultiSelect(options: UpdateMultiSelectOptions): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* 메시지에 이모지 리액션 추가
|
|
65
|
+
* already_reacted 에러는 무시
|
|
66
|
+
*/
|
|
67
|
+
export declare function addReaction(client: WebClient, channelId: string, ts: string, emoji: string): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* 메시지에서 이모지 리액션 제거
|
|
70
|
+
* no_reaction 에러는 무시
|
|
71
|
+
*/
|
|
72
|
+
export declare function removeReaction(client: WebClient, channelId: string, ts: string, emoji: string): Promise<void>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { buildQuestionBlocks } from "../slack/question-blocks.js";
|
|
2
|
+
import { createLogger } from "../core/logger.js";
|
|
3
|
+
import { t } from "../i18n/index.js";
|
|
4
|
+
import { getState } from "../stores/multi-select-state.js";
|
|
5
|
+
import { withRetry } from "./slack-rate-limit.js";
|
|
6
|
+
const log = createLogger("slack-message");
|
|
7
|
+
/**
|
|
8
|
+
* Slack 메시지 텍스트 최대 길이.
|
|
9
|
+
* Slack은 40,000자에서 메시지를 잘라내므로 약간의 여유를 두고 39,000자로 설정.
|
|
10
|
+
*/
|
|
11
|
+
export const SLACK_MAX_TEXT_LENGTH = 39_000;
|
|
12
|
+
/**
|
|
13
|
+
* 텍스트에서 열린(닫히지 않은) 코드 펜스가 있는지 확인.
|
|
14
|
+
* 열린 코드 펜스가 있으면 해당 펜스의 언어 식별자를 반환, 없으면 null.
|
|
15
|
+
*/
|
|
16
|
+
export function getUnclosedCodeFence(text) {
|
|
17
|
+
const fenceRegex = /^[ \t]*```(\S*)/gm;
|
|
18
|
+
let isOpen = false;
|
|
19
|
+
let lang = "";
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = fenceRegex.exec(text)) !== null) {
|
|
22
|
+
if (!isOpen) {
|
|
23
|
+
isOpen = true;
|
|
24
|
+
lang = match[1] || "";
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
isOpen = false;
|
|
28
|
+
lang = "";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return isOpen ? lang : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 분할로 깨진 코드 블록을 수정.
|
|
35
|
+
* 열린 코드 블록이 있는 청크에 닫는 펜스를 추가하고,
|
|
36
|
+
* 다음 청크에 여는 펜스를 추가.
|
|
37
|
+
*/
|
|
38
|
+
function repairCodeBlocks(chunks) {
|
|
39
|
+
if (chunks.length <= 1)
|
|
40
|
+
return chunks;
|
|
41
|
+
const result = [];
|
|
42
|
+
let pendingOpen = "";
|
|
43
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
44
|
+
let chunk = pendingOpen + chunks[i];
|
|
45
|
+
pendingOpen = "";
|
|
46
|
+
if (i < chunks.length - 1) {
|
|
47
|
+
const lang = getUnclosedCodeFence(chunk);
|
|
48
|
+
if (lang !== null) {
|
|
49
|
+
chunk += "\n```";
|
|
50
|
+
pendingOpen = "```" + lang + "\n";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
result.push(chunk);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 긴 텍스트를 Slack 메시지 길이 제한에 맞게 분할.
|
|
59
|
+
* 가능하면 단락 경계(\n\n), 줄바꿈(\n) 순으로 자연스러운 위치에서 분할.
|
|
60
|
+
* 코드 블록(```)이 분할로 깨지면 자동으로 닫고 다음 청크에서 다시 열어줌.
|
|
61
|
+
*/
|
|
62
|
+
export function splitText(text, maxLength = SLACK_MAX_TEXT_LENGTH) {
|
|
63
|
+
if (text.length <= maxLength)
|
|
64
|
+
return [text];
|
|
65
|
+
const chunks = [];
|
|
66
|
+
let remaining = text;
|
|
67
|
+
while (remaining.length > 0) {
|
|
68
|
+
if (remaining.length <= maxLength) {
|
|
69
|
+
chunks.push(remaining);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
// 단락 경계(\n\n)에서 분할 시도
|
|
73
|
+
let splitIndex = remaining.lastIndexOf("\n\n", maxLength);
|
|
74
|
+
if (splitIndex <= 0) {
|
|
75
|
+
// 줄바꿈(\n)에서 분할 시도
|
|
76
|
+
splitIndex = remaining.lastIndexOf("\n", maxLength);
|
|
77
|
+
}
|
|
78
|
+
if (splitIndex <= 0) {
|
|
79
|
+
// 자연스러운 분할 지점이 없으면 강제 분할
|
|
80
|
+
splitIndex = maxLength;
|
|
81
|
+
}
|
|
82
|
+
chunks.push(remaining.slice(0, splitIndex));
|
|
83
|
+
remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
|
|
84
|
+
}
|
|
85
|
+
return repairCodeBlocks(chunks);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 스레드에 메시지 전송.
|
|
89
|
+
* Slack 메시지 길이 제한(40,000자)을 초과하면 자동으로 분할 전송.
|
|
90
|
+
*/
|
|
91
|
+
export async function postThreadMessage(client, channelId, text, threadTs) {
|
|
92
|
+
if (!text) {
|
|
93
|
+
log.warn("Skipping empty message", { channelId, threadTs });
|
|
94
|
+
return { success: true, ts: undefined };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const chunks = splitText(text);
|
|
98
|
+
let lastTs;
|
|
99
|
+
for (const chunk of chunks) {
|
|
100
|
+
const result = await withRetry(() => client.chat.postMessage({
|
|
101
|
+
channel: channelId,
|
|
102
|
+
text: chunk,
|
|
103
|
+
thread_ts: threadTs,
|
|
104
|
+
}));
|
|
105
|
+
lastTs = result.ts;
|
|
106
|
+
}
|
|
107
|
+
return { success: true, ts: lastTs };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
log.error("Failed to post thread message", { error, channelId, threadTs });
|
|
111
|
+
return { success: false, error };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 채널에 메시지 전송 (스레드 없음)
|
|
116
|
+
*/
|
|
117
|
+
export async function postChannelMessage(client, channelId, text) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await withRetry(() => client.chat.postMessage({
|
|
120
|
+
channel: channelId,
|
|
121
|
+
text,
|
|
122
|
+
}));
|
|
123
|
+
return { success: true, ts: result.ts };
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
log.error("Failed to post channel message", error);
|
|
127
|
+
return { success: false, error };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Slack 메시지 업데이트 (버튼 클릭 후 상태 반영)
|
|
132
|
+
*/
|
|
133
|
+
export async function updateSlackMessage(options) {
|
|
134
|
+
const { client, channelId, ts, projectName, messageId, question, selectedAnswer, isSubmitted, } = options;
|
|
135
|
+
const blocks = buildQuestionBlocks({
|
|
136
|
+
question,
|
|
137
|
+
projectName,
|
|
138
|
+
messageId,
|
|
139
|
+
selectedAnswer,
|
|
140
|
+
isSubmitted,
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
await withRetry(() => client.chat.update({
|
|
144
|
+
channel: channelId,
|
|
145
|
+
ts,
|
|
146
|
+
text: isSubmitted ? t("slack.answered") : t("slack.question"),
|
|
147
|
+
blocks,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
log.error("Failed to update message", error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 복수 선택 토글 후 Slack 메시지 업데이트
|
|
156
|
+
* multi-select-state에서 옵션 정보를 가져와서 blocks 재생성
|
|
157
|
+
*/
|
|
158
|
+
export async function updateSlackMessageWithMultiSelect(options) {
|
|
159
|
+
const { client, channelId, ts, projectName, messageId, selectedOptionIndexes, } = options;
|
|
160
|
+
// 상태에서 옵션 정보 가져오기
|
|
161
|
+
const state = getState(projectName, messageId);
|
|
162
|
+
if (!state) {
|
|
163
|
+
log.error("Multi-select state not found", { projectName, messageId });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const question = {
|
|
167
|
+
question: state.questionText,
|
|
168
|
+
header: state.header,
|
|
169
|
+
options: state.options,
|
|
170
|
+
multiSelect: true,
|
|
171
|
+
};
|
|
172
|
+
const blocks = buildQuestionBlocks({
|
|
173
|
+
question,
|
|
174
|
+
projectName,
|
|
175
|
+
messageId,
|
|
176
|
+
isSubmitted: false,
|
|
177
|
+
selectedOptionIndexes,
|
|
178
|
+
});
|
|
179
|
+
try {
|
|
180
|
+
await withRetry(() => client.chat.update({
|
|
181
|
+
channel: channelId,
|
|
182
|
+
ts,
|
|
183
|
+
text: t("slack.question"),
|
|
184
|
+
blocks,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
log.error("Failed to update multi-select message", error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 메시지에 이모지 리액션 추가
|
|
193
|
+
* already_reacted 에러는 무시
|
|
194
|
+
*/
|
|
195
|
+
export async function addReaction(client, channelId, ts, emoji) {
|
|
196
|
+
try {
|
|
197
|
+
await withRetry(() => client.reactions.add({ channel: channelId, timestamp: ts, name: emoji }));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const slackError = error;
|
|
201
|
+
if (slackError.data?.error === "already_reacted")
|
|
202
|
+
return;
|
|
203
|
+
log.error("Failed to add reaction", { error, channelId, ts, emoji });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 메시지에서 이모지 리액션 제거
|
|
208
|
+
* no_reaction 에러는 무시
|
|
209
|
+
*/
|
|
210
|
+
export async function removeReaction(client, channelId, ts, emoji) {
|
|
211
|
+
try {
|
|
212
|
+
await withRetry(() => client.reactions.remove({ channel: channelId, timestamp: ts, name: emoji }));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const slackError = error;
|
|
216
|
+
if (slackError.data?.error === "no_reaction")
|
|
217
|
+
return;
|
|
218
|
+
log.error("Failed to remove reaction", { error, channelId, ts, emoji });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createLogger } from "../core/logger.js";
|
|
2
|
+
const log = createLogger("slack-rate-limit");
|
|
3
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Slack API 호출을 429 (rate_limited) 에러에 대해 자동 재시도하는 래퍼.
|
|
6
|
+
* Slack의 Retry-After 값을 존중하며, 최대 maxRetries 회까지 재시도.
|
|
7
|
+
*/
|
|
8
|
+
export async function withRetry(fn, maxRetries = DEFAULT_MAX_RETRIES) {
|
|
9
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
return await fn();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
const isRateLimited = isRateLimitedError(error);
|
|
15
|
+
if (!isRateLimited || attempt === maxRetries) {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
const retryAfterSec = parseRetryAfter(error) ?? 1;
|
|
19
|
+
const waitMs = retryAfterSec * 1000;
|
|
20
|
+
log.warn("Rate limited by Slack, retrying", {
|
|
21
|
+
attempt: attempt + 1,
|
|
22
|
+
maxRetries,
|
|
23
|
+
waitMs,
|
|
24
|
+
});
|
|
25
|
+
await sleep(waitMs);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// TypeScript needs this, but it's unreachable
|
|
29
|
+
throw new Error("withRetry: exhausted retries");
|
|
30
|
+
}
|
|
31
|
+
function isRateLimitedError(error) {
|
|
32
|
+
const err = error;
|
|
33
|
+
return err?.data?.error === "rate_limited";
|
|
34
|
+
}
|
|
35
|
+
function parseRetryAfter(error) {
|
|
36
|
+
const err = error;
|
|
37
|
+
if (typeof err?.retryAfter === "number")
|
|
38
|
+
return err.retryAfter;
|
|
39
|
+
const header = err?.headers?.["retry-after"] ?? err?.headers?.["Retry-After"];
|
|
40
|
+
if (header) {
|
|
41
|
+
const parsed = parseInt(header, 10);
|
|
42
|
+
if (!isNaN(parsed))
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function sleep(ms) {
|
|
48
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Store
|
|
3
|
+
*
|
|
4
|
+
* /twindevbot goto 로 생성된 스레드와 작업 디렉토리의 매핑을 관리합니다.
|
|
5
|
+
* 스레드에 첫 메시지가 오면 이 매핑을 사용하여 Claude 세션을 시작합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: threadTs (Slack 스레드 부모 메시지 타임스탬프)
|
|
8
|
+
* 값: { directory, projectName, channelId }
|
|
9
|
+
*/
|
|
10
|
+
export interface Workspace {
|
|
11
|
+
directory: string;
|
|
12
|
+
projectName: string;
|
|
13
|
+
channelId: string;
|
|
14
|
+
autopilot?: boolean;
|
|
15
|
+
createdAt?: Date;
|
|
16
|
+
}
|
|
17
|
+
export declare function addWorkspace(threadTs: string, workspace: Workspace): void;
|
|
18
|
+
export declare function getWorkspace(threadTs: string): Workspace | undefined;
|
|
19
|
+
export declare function removeWorkspace(threadTs: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
22
|
+
*/
|
|
23
|
+
export declare function stopCleanupTimer(): void;
|
|
24
|
+
/**
|
|
25
|
+
* 테스트용: cleanup 수동 실행
|
|
26
|
+
*/
|
|
27
|
+
export declare function runCleanup(): number;
|