companionbot 0.4.3 → 0.5.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.
- package/dist/agents/index.js +1 -1
- package/dist/agents/manager.js +30 -0
- package/dist/ai/claude.js +4 -3
- package/dist/cron/parser.js +153 -20
- package/dist/cron/scheduler.js +7 -3
- package/dist/heartbeat/index.js +5 -1
- package/dist/telegram/bot.js +3 -3
- package/dist/telegram/handlers/commands.js +39 -6
- package/dist/telegram/handlers/messages.js +115 -5
- package/dist/telegram/utils/index.js +2 -0
- package/dist/telegram/utils/prompt.js +10 -7
- package/dist/telegram/utils/secrets.js +64 -0
- package/dist/tools/index.js +360 -19
- package/dist/workspace/index.js +3 -1
- package/dist/workspace/load.js +251 -7
- package/package.json +1 -1
- package/templates/AGENTS.md +77 -10
- package/templates/MEMORY.md +58 -4
package/dist/agents/index.js
CHANGED
package/dist/agents/manager.js
CHANGED
|
@@ -198,6 +198,14 @@ export function cleanupOldAgents() {
|
|
|
198
198
|
// running 상태도 1시간 지나면 정리 (stuck agent 방지)
|
|
199
199
|
if (agent.status === "running" && agent.createdAt.getTime() < oneHourAgo) {
|
|
200
200
|
console.log(`[Agent ${id}] Cleaning up stuck agent (running > 1h)`);
|
|
201
|
+
// 실행 중인 API 호출 취소
|
|
202
|
+
const controller = abortControllers.get(id);
|
|
203
|
+
if (controller) {
|
|
204
|
+
controller.abort();
|
|
205
|
+
abortControllers.delete(id);
|
|
206
|
+
}
|
|
207
|
+
agent.status = "cancelled";
|
|
208
|
+
agent.completedAt = new Date();
|
|
201
209
|
agents.delete(id);
|
|
202
210
|
}
|
|
203
211
|
}
|
|
@@ -223,5 +231,27 @@ export function stopCleanup() {
|
|
|
223
231
|
console.log("[AgentManager] Cleanup interval stopped");
|
|
224
232
|
}
|
|
225
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* 모든 진행 중인 agent abort 및 정리 (shutdown 시 사용)
|
|
236
|
+
*/
|
|
237
|
+
export function abortAllAgents() {
|
|
238
|
+
console.log("[AgentManager] Aborting all running agents...");
|
|
239
|
+
// 모든 AbortController abort
|
|
240
|
+
for (const [id, controller] of abortControllers) {
|
|
241
|
+
console.log(`[Agent ${id}] Aborting`);
|
|
242
|
+
controller.abort();
|
|
243
|
+
}
|
|
244
|
+
abortControllers.clear();
|
|
245
|
+
// 모든 running agent 상태 업데이트
|
|
246
|
+
for (const [id, agent] of agents) {
|
|
247
|
+
if (agent.status === "running") {
|
|
248
|
+
agent.status = "cancelled";
|
|
249
|
+
agent.completedAt = new Date();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// cleanup interval 중지
|
|
253
|
+
stopCleanup();
|
|
254
|
+
console.log("[AgentManager] All agents aborted");
|
|
255
|
+
}
|
|
226
256
|
// 자동 시작
|
|
227
257
|
startCleanup();
|
package/dist/ai/claude.js
CHANGED
|
@@ -12,9 +12,10 @@ export const MODELS = {
|
|
|
12
12
|
opus: { id: "claude-opus-4-20250514", name: "Claude Opus 4" },
|
|
13
13
|
haiku: { id: "claude-haiku-3-5-20241022", name: "Claude Haiku 3.5" },
|
|
14
14
|
};
|
|
15
|
-
export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
15
|
+
export async function chat(messages, systemPrompt, modelId = "sonnet", options) {
|
|
16
16
|
const client = getClient();
|
|
17
17
|
const model = MODELS[modelId].id;
|
|
18
|
+
const signal = options?.signal;
|
|
18
19
|
// 메시지를 API 형식으로 변환
|
|
19
20
|
const apiMessages = messages.map((m) => ({
|
|
20
21
|
role: m.role,
|
|
@@ -28,7 +29,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
28
29
|
system: systemPrompt,
|
|
29
30
|
messages: apiMessages,
|
|
30
31
|
tools: tools,
|
|
31
|
-
});
|
|
32
|
+
}, { signal });
|
|
32
33
|
}
|
|
33
34
|
catch (error) {
|
|
34
35
|
if (error instanceof Anthropic.APIError) {
|
|
@@ -79,7 +80,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
79
80
|
system: systemPrompt,
|
|
80
81
|
messages: apiMessages,
|
|
81
82
|
tools: tools,
|
|
82
|
-
});
|
|
83
|
+
}, { signal });
|
|
83
84
|
}
|
|
84
85
|
catch (error) {
|
|
85
86
|
if (error instanceof Anthropic.APIError) {
|
package/dist/cron/parser.js
CHANGED
|
@@ -130,30 +130,163 @@ export function parseCronExpression(expr) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
|
-
*
|
|
133
|
+
* Get time components in a specific timezone using Intl API
|
|
134
|
+
*/
|
|
135
|
+
function getTimeInTimezone(date, timezone) {
|
|
136
|
+
try {
|
|
137
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
138
|
+
timeZone: timezone,
|
|
139
|
+
year: "numeric",
|
|
140
|
+
month: "2-digit",
|
|
141
|
+
day: "2-digit",
|
|
142
|
+
hour: "2-digit",
|
|
143
|
+
minute: "2-digit",
|
|
144
|
+
weekday: "short",
|
|
145
|
+
hour12: false,
|
|
146
|
+
});
|
|
147
|
+
const parts = formatter.formatToParts(date);
|
|
148
|
+
const getValue = (type) => parts.find((p) => p.type === type)?.value ?? "0";
|
|
149
|
+
const dayOfWeekMap = {
|
|
150
|
+
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
year: parseInt(getValue("year"), 10),
|
|
154
|
+
month: parseInt(getValue("month"), 10),
|
|
155
|
+
day: parseInt(getValue("day"), 10),
|
|
156
|
+
hour: parseInt(getValue("hour"), 10),
|
|
157
|
+
minute: parseInt(getValue("minute"), 10),
|
|
158
|
+
dayOfWeek: dayOfWeekMap[getValue("weekday")] ?? 0,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Fallback to local time if timezone is invalid
|
|
163
|
+
return {
|
|
164
|
+
year: date.getFullYear(),
|
|
165
|
+
month: date.getMonth() + 1,
|
|
166
|
+
day: date.getDate(),
|
|
167
|
+
hour: date.getHours(),
|
|
168
|
+
minute: date.getMinutes(),
|
|
169
|
+
dayOfWeek: date.getDay(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Create a Date object for a specific time in a timezone
|
|
175
|
+
*/
|
|
176
|
+
function createDateInTimezone(year, month, day, hour, minute, timezone) {
|
|
177
|
+
try {
|
|
178
|
+
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
|
|
179
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
180
|
+
timeZone: timezone,
|
|
181
|
+
year: "numeric",
|
|
182
|
+
month: "2-digit",
|
|
183
|
+
day: "2-digit",
|
|
184
|
+
hour: "2-digit",
|
|
185
|
+
minute: "2-digit",
|
|
186
|
+
hour12: false,
|
|
187
|
+
});
|
|
188
|
+
// Binary search for the correct UTC time
|
|
189
|
+
let guess = new Date(dateStr);
|
|
190
|
+
for (let i = 0; i < 3; i++) {
|
|
191
|
+
const parts = formatter.formatToParts(guess);
|
|
192
|
+
const getValue = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
|
|
193
|
+
const guessHour = getValue("hour");
|
|
194
|
+
const guessMinute = getValue("minute");
|
|
195
|
+
const guessDay = getValue("day");
|
|
196
|
+
let diffMinutes = (hour - guessHour) * 60 + (minute - guessMinute);
|
|
197
|
+
if (day !== guessDay) {
|
|
198
|
+
if (day > guessDay || (day === 1 && guessDay > 20)) {
|
|
199
|
+
diffMinutes += 24 * 60;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
diffMinutes -= 24 * 60;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (diffMinutes === 0)
|
|
206
|
+
break;
|
|
207
|
+
guess = new Date(guess.getTime() + diffMinutes * 60 * 1000);
|
|
208
|
+
}
|
|
209
|
+
return guess;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Get number of days in a month
|
|
217
|
+
*/
|
|
218
|
+
function getDaysInMonth(year, month) {
|
|
219
|
+
return new Date(year, month, 0).getDate();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Calculate the next run time for a cron expression with timezone support
|
|
134
223
|
*/
|
|
135
224
|
export function getNextCronRun(expression, fromDate = new Date(), timezone) {
|
|
136
225
|
const parsed = parseCronExpression(expression);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const maxIterations =
|
|
226
|
+
const now = fromDate;
|
|
227
|
+
const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
228
|
+
// Get current time in target timezone
|
|
229
|
+
const current = getTimeInTimezone(now, tz);
|
|
230
|
+
// Search for next valid time (up to 366 days ahead)
|
|
231
|
+
const searchDate = { ...current };
|
|
232
|
+
const maxIterations = 366 * 24 * 60;
|
|
144
233
|
for (let i = 0; i < maxIterations; i++) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
234
|
+
// Advance by one minute each iteration
|
|
235
|
+
if (i > 0) {
|
|
236
|
+
searchDate.minute++;
|
|
237
|
+
if (searchDate.minute > 59) {
|
|
238
|
+
searchDate.minute = 0;
|
|
239
|
+
searchDate.hour++;
|
|
240
|
+
if (searchDate.hour > 23) {
|
|
241
|
+
searchDate.hour = 0;
|
|
242
|
+
searchDate.day++;
|
|
243
|
+
searchDate.dayOfWeek = (searchDate.dayOfWeek + 1) % 7;
|
|
244
|
+
const daysInMonth = getDaysInMonth(searchDate.year, searchDate.month);
|
|
245
|
+
if (searchDate.day > daysInMonth) {
|
|
246
|
+
searchDate.day = 1;
|
|
247
|
+
searchDate.month++;
|
|
248
|
+
if (searchDate.month > 12) {
|
|
249
|
+
searchDate.month = 1;
|
|
250
|
+
searchDate.year++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Check if this time matches all constraints
|
|
257
|
+
if (!parsed.month.values.includes(searchDate.month))
|
|
258
|
+
continue;
|
|
259
|
+
// For dayOfMonth and dayOfWeek: if both are restricted, match either (OR logic)
|
|
260
|
+
// If only one is restricted, use that one
|
|
261
|
+
const domRestricted = parsed.dayOfMonth.type !== "wildcard";
|
|
262
|
+
const dowRestricted = parsed.dayOfWeek.type !== "wildcard";
|
|
263
|
+
if (domRestricted && dowRestricted) {
|
|
264
|
+
// Both restricted: match either
|
|
265
|
+
if (!parsed.dayOfMonth.values.includes(searchDate.day) &&
|
|
266
|
+
!parsed.dayOfWeek.values.includes(searchDate.dayOfWeek)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (domRestricted) {
|
|
271
|
+
if (!parsed.dayOfMonth.values.includes(searchDate.day))
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
else if (dowRestricted) {
|
|
275
|
+
if (!parsed.dayOfWeek.values.includes(searchDate.dayOfWeek))
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!parsed.hour.values.includes(searchDate.hour))
|
|
279
|
+
continue;
|
|
280
|
+
if (!parsed.minute.values.includes(searchDate.minute))
|
|
281
|
+
continue;
|
|
282
|
+
// Skip if this is the current minute (must be in the future)
|
|
283
|
+
if (i === 0)
|
|
284
|
+
continue;
|
|
285
|
+
// Create the date in the target timezone
|
|
286
|
+
const nextRun = createDateInTimezone(searchDate.year, searchDate.month, searchDate.day, searchDate.hour, searchDate.minute, tz);
|
|
287
|
+
// Verify it's in the future
|
|
288
|
+
if (nextRun > now) {
|
|
289
|
+
return nextRun;
|
|
157
290
|
}
|
|
158
291
|
}
|
|
159
292
|
throw new Error(`Could not find next run time for: ${expression}`);
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { getDueJobs, markJobExecuted, loadJobs, addJob, removeJob, updateJob, getJobsByChat } from "./store.js";
|
|
8
8
|
import { chat } from "../ai/claude.js";
|
|
9
9
|
import { buildSystemPrompt } from "../telegram/utils/prompt.js";
|
|
10
|
+
import { runWithChatId } from "../session/state.js";
|
|
10
11
|
// Scheduler state
|
|
11
12
|
let schedulerInterval = null;
|
|
12
13
|
let botInstance = null;
|
|
@@ -145,7 +146,8 @@ async function executeSystemEvent(job, payload, bot) {
|
|
|
145
146
|
*/
|
|
146
147
|
async function executeAgentTurn(job, payload, bot) {
|
|
147
148
|
const { message: inputMessage, context } = payload;
|
|
148
|
-
|
|
149
|
+
// Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
|
|
150
|
+
const response = await runWithChatId(job.chatId, async () => {
|
|
149
151
|
// Build a fresh conversation for this job (separate from main chat)
|
|
150
152
|
const messages = [
|
|
151
153
|
{
|
|
@@ -165,8 +167,10 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
165
167
|
- Run Count: ${(job.runCount || 0) + 1}
|
|
166
168
|
- This is a scheduled task, not a direct user message.`;
|
|
167
169
|
// Call Claude API
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
return await chat(messages, systemPrompt, "sonnet");
|
|
171
|
+
});
|
|
172
|
+
// Send the response to the chat
|
|
173
|
+
try {
|
|
170
174
|
if (response && response.trim()) {
|
|
171
175
|
// Split long messages (Telegram limit is 4096 characters)
|
|
172
176
|
const maxLength = 4000;
|
package/dist/heartbeat/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getWorkspacePath } from "../workspace/index.js";
|
|
|
4
4
|
import { chat } from "../ai/claude.js";
|
|
5
5
|
import { isCalendarConfigured, getTodayEvents, formatEvent } from "../calendar/index.js";
|
|
6
6
|
import { getSecret } from "../config/secrets.js";
|
|
7
|
+
import { runWithChatId } from "../session/state.js";
|
|
7
8
|
// 활성 타이머
|
|
8
9
|
const activeTimers = new Map();
|
|
9
10
|
// 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
|
|
@@ -120,7 +121,10 @@ ${context}
|
|
|
120
121
|
];
|
|
121
122
|
let messageSent = false;
|
|
122
123
|
try {
|
|
123
|
-
|
|
124
|
+
// Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
|
|
125
|
+
const response = await runWithChatId(config.chatId, async () => {
|
|
126
|
+
return await chat(messages, systemPrompt, "haiku");
|
|
127
|
+
});
|
|
124
128
|
if (!response.trim().includes("HEARTBEAT_OK")) {
|
|
125
129
|
await botInstance.api.sendMessage(config.chatId, response);
|
|
126
130
|
console.log(`[Heartbeat] Sent message to ${config.chatId}`);
|
package/dist/telegram/bot.js
CHANGED
|
@@ -27,12 +27,12 @@ export function createBot(token) {
|
|
|
27
27
|
// Cron 시스템 초기화
|
|
28
28
|
setCronBot(bot);
|
|
29
29
|
restoreCronJobs().catch((err) => console.error("Failed to restore cron jobs:", err));
|
|
30
|
-
// Rate limiting - 1분에
|
|
30
|
+
// Rate limiting - 1분에 20개 메시지
|
|
31
31
|
bot.use(limit({
|
|
32
32
|
timeFrame: 60000, // 1분
|
|
33
|
-
limit:
|
|
33
|
+
limit: 20,
|
|
34
34
|
onLimitExceeded: async (ctx) => {
|
|
35
|
-
await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요.
|
|
35
|
+
await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요. 30초 후 다시 시도해주세요.");
|
|
36
36
|
},
|
|
37
37
|
}));
|
|
38
38
|
// 에러 핸들링
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
1
2
|
import { randomBytes } from "crypto";
|
|
2
3
|
import { chat, MODELS } from "../../ai/claude.js";
|
|
3
4
|
// Reset 토큰 관리 (1분 만료)
|
|
@@ -74,16 +75,48 @@ export function registerCommands(bot) {
|
|
|
74
75
|
`/reset - 페르소나 리셋`);
|
|
75
76
|
}
|
|
76
77
|
});
|
|
77
|
-
// /reset 명령어 - 페르소나 리셋 (토큰 기반)
|
|
78
|
+
// /reset 명령어 - 페르소나 리셋 (인라인 버튼 + 토큰 기반)
|
|
78
79
|
bot.command("reset", async (ctx) => {
|
|
79
80
|
const chatId = ctx.chat.id;
|
|
80
81
|
const token = generateResetToken(chatId);
|
|
81
|
-
|
|
82
|
-
"
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
const keyboard = new InlineKeyboard()
|
|
83
|
+
.text("✅ 예, 리셋합니다", `reset_confirm_${token}`)
|
|
84
|
+
.text("❌ 취소", "reset_cancel");
|
|
85
|
+
await ctx.reply("⚠️ 정말 페르소나를 리셋할까요?\n\n" +
|
|
86
|
+
"모든 설정이 초기화되고 온보딩을 다시 진행합니다.\n" +
|
|
87
|
+
"(1분 후 버튼 만료)", { reply_markup: keyboard });
|
|
85
88
|
});
|
|
86
|
-
//
|
|
89
|
+
// Reset 확인 버튼 콜백
|
|
90
|
+
bot.callbackQuery(/^reset_confirm_([a-f0-9]+)$/, async (ctx) => {
|
|
91
|
+
const chatId = ctx.chat.id;
|
|
92
|
+
const token = ctx.match[1];
|
|
93
|
+
if (!validateResetToken(chatId, token)) {
|
|
94
|
+
await ctx.answerCallbackQuery({ text: "❌ 만료된 요청입니다. /reset 으로 다시 시도하세요." });
|
|
95
|
+
await ctx.editMessageText("❌ 만료된 요청입니다.\n/reset 으로 다시 시도하세요.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { initWorkspace } = await import("../../workspace/index.js");
|
|
99
|
+
const { rm } = await import("fs/promises");
|
|
100
|
+
try {
|
|
101
|
+
await ctx.answerCallbackQuery({ text: "리셋 중..." });
|
|
102
|
+
await rm(getWorkspacePath(), { recursive: true, force: true });
|
|
103
|
+
await initWorkspace();
|
|
104
|
+
invalidateWorkspaceCache();
|
|
105
|
+
clearHistory(chatId);
|
|
106
|
+
await ctx.editMessageText("✓ 페르소나가 리셋되었습니다.\n\n" +
|
|
107
|
+
"/start 를 눌러 온보딩을 시작하세요.");
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error("Reset error:", error);
|
|
111
|
+
await ctx.editMessageText("❌ 리셋 중 오류가 발생했습니다.");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
// Reset 취소 버튼 콜백
|
|
115
|
+
bot.callbackQuery("reset_cancel", async (ctx) => {
|
|
116
|
+
await ctx.answerCallbackQuery({ text: "취소됨" });
|
|
117
|
+
await ctx.editMessageText("✓ 리셋이 취소되었습니다.");
|
|
118
|
+
});
|
|
119
|
+
// /confirm_reset_<token> 패턴 매칭 (레거시 - 텍스트 입력 지원)
|
|
87
120
|
bot.hears(/^\/confirm_reset_([a-f0-9]+)$/, async (ctx) => {
|
|
88
121
|
const chatId = ctx.chat.id;
|
|
89
122
|
const token = ctx.match[1];
|
|
@@ -1,7 +1,70 @@
|
|
|
1
1
|
import { chat } from "../../ai/claude.js";
|
|
2
2
|
import { getHistory, getModel, runWithChatId, } from "../../session/state.js";
|
|
3
3
|
import { updateLastMessageTime } from "../../heartbeat/index.js";
|
|
4
|
-
import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
|
|
4
|
+
import { extractUrls, fetchWebContent, buildSystemPrompt, detectSecrets, } from "../utils/index.js";
|
|
5
|
+
// 채팅별 AbortController 관리 (race condition 방지)
|
|
6
|
+
const chatAbortControllers = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* 이전 요청을 취소하고 새 AbortController 생성
|
|
9
|
+
*/
|
|
10
|
+
function getNewAbortController(chatId) {
|
|
11
|
+
// 이전 요청 취소
|
|
12
|
+
const previous = chatAbortControllers.get(chatId);
|
|
13
|
+
if (previous) {
|
|
14
|
+
previous.abort();
|
|
15
|
+
}
|
|
16
|
+
// 새 controller 생성
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
chatAbortControllers.set(chatId, controller);
|
|
19
|
+
return controller;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 완료된 요청의 controller 정리
|
|
23
|
+
*/
|
|
24
|
+
function cleanupAbortController(chatId, controller) {
|
|
25
|
+
// 현재 저장된 controller와 같은 경우에만 삭제 (새 요청이 없을 때)
|
|
26
|
+
if (chatAbortControllers.get(chatId) === controller) {
|
|
27
|
+
chatAbortControllers.delete(chatId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 모든 진행 중인 요청 취소 (shutdown 시 사용)
|
|
32
|
+
*/
|
|
33
|
+
export function abortAllChatRequests() {
|
|
34
|
+
for (const [chatId, controller] of chatAbortControllers) {
|
|
35
|
+
controller.abort();
|
|
36
|
+
}
|
|
37
|
+
chatAbortControllers.clear();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* API 키가 포함된 메시지를 처리합니다.
|
|
41
|
+
* 메시지를 삭제하고 사용자에게 경고합니다.
|
|
42
|
+
* @returns true if secret was detected and handled
|
|
43
|
+
*/
|
|
44
|
+
async function handleSecretDetection(ctx, message) {
|
|
45
|
+
const result = detectSecrets(message);
|
|
46
|
+
if (!result.detected) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// 원본 메시지 삭제 시도
|
|
50
|
+
try {
|
|
51
|
+
if (ctx.message?.message_id) {
|
|
52
|
+
await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// 삭제 실패해도 계속 진행 (권한 없을 수 있음)
|
|
57
|
+
console.warn("Failed to delete message with secret:", error);
|
|
58
|
+
}
|
|
59
|
+
// 경고 메시지 전송
|
|
60
|
+
const typeList = result.types.join(", ");
|
|
61
|
+
await ctx.reply(`⚠️ **API 키 감지됨!**\n\n` +
|
|
62
|
+
`방금 보낸 메시지에서 ${typeList} 키가 감지되어 삭제했어.\n\n` +
|
|
63
|
+
`🔐 API 키는 채팅에 절대 입력하면 안 돼!\n` +
|
|
64
|
+
`CLI에서 \`companionbot config\` 명령어로 안전하게 설정해줘.`, { parse_mode: "Markdown" });
|
|
65
|
+
console.log(`[Security] Blocked API key exposure: ${typeList}`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
5
68
|
/**
|
|
6
69
|
* 메시지 핸들러들을 봇에 등록합니다.
|
|
7
70
|
*/
|
|
@@ -9,6 +72,13 @@ export function registerMessageHandlers(bot) {
|
|
|
9
72
|
// 사진 메시지 처리
|
|
10
73
|
bot.on("message:photo", async (ctx) => {
|
|
11
74
|
const chatId = ctx.chat.id;
|
|
75
|
+
const caption = ctx.message.caption || "";
|
|
76
|
+
// 캡션에서 API 키 감지
|
|
77
|
+
if (caption && await handleSecretDetection(ctx, caption)) {
|
|
78
|
+
return; // 메시지 삭제됨, 처리 중단
|
|
79
|
+
}
|
|
80
|
+
// 이전 요청 취소하고 새 controller 생성 (race condition 방지)
|
|
81
|
+
const controller = getNewAbortController(chatId);
|
|
12
82
|
await runWithChatId(chatId, async () => {
|
|
13
83
|
const history = getHistory(chatId);
|
|
14
84
|
const modelId = getModel(chatId);
|
|
@@ -33,7 +103,7 @@ export function registerMessageHandlers(bot) {
|
|
|
33
103
|
const buffer = await response.arrayBuffer();
|
|
34
104
|
const base64 = Buffer.from(buffer).toString("base64");
|
|
35
105
|
// 캡션이 있으면 사용, 없으면 기본 질문
|
|
36
|
-
const
|
|
106
|
+
const photoCaption = caption || "이 사진에 뭐가 있어?";
|
|
37
107
|
// 이미지와 텍스트를 함께 전송
|
|
38
108
|
const imageContent = [
|
|
39
109
|
{
|
|
@@ -46,12 +116,17 @@ export function registerMessageHandlers(bot) {
|
|
|
46
116
|
},
|
|
47
117
|
{
|
|
48
118
|
type: "text",
|
|
49
|
-
text:
|
|
119
|
+
text: photoCaption,
|
|
50
120
|
},
|
|
51
121
|
];
|
|
52
122
|
history.push({ role: "user", content: imageContent });
|
|
53
123
|
const systemPrompt = await buildSystemPrompt(modelId);
|
|
54
|
-
const result = await chat(history, systemPrompt, modelId);
|
|
124
|
+
const result = await chat(history, systemPrompt, modelId, { signal: controller.signal });
|
|
125
|
+
// abort된 경우 히스토리 롤백
|
|
126
|
+
if (controller.signal.aborted) {
|
|
127
|
+
history.pop();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
55
130
|
history.push({ role: "assistant", content: result });
|
|
56
131
|
// 히스토리 제한
|
|
57
132
|
if (history.length > 20) {
|
|
@@ -60,9 +135,21 @@ export function registerMessageHandlers(bot) {
|
|
|
60
135
|
await ctx.reply(result);
|
|
61
136
|
}
|
|
62
137
|
catch (error) {
|
|
138
|
+
// abort로 인한 에러는 무시
|
|
139
|
+
if (controller.signal.aborted) {
|
|
140
|
+
console.log(`[Photo] Request aborted for chat ${chatId}`);
|
|
141
|
+
// 유저 메시지 롤백 (이미 추가된 경우)
|
|
142
|
+
if (history.length > 0 && history[history.length - 1].role === "user") {
|
|
143
|
+
history.pop();
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
63
147
|
console.error("Photo error:", error);
|
|
64
148
|
await ctx.reply("사진 분석 중 오류가 발생했어.");
|
|
65
149
|
}
|
|
150
|
+
finally {
|
|
151
|
+
cleanupAbortController(chatId, controller);
|
|
152
|
+
}
|
|
66
153
|
});
|
|
67
154
|
});
|
|
68
155
|
// 일반 메시지 처리
|
|
@@ -72,6 +159,12 @@ export function registerMessageHandlers(bot) {
|
|
|
72
159
|
// 빈 메시지 무시
|
|
73
160
|
if (!userMessage.trim())
|
|
74
161
|
return;
|
|
162
|
+
// API 키 감지 - 히스토리에 저장하지 않고 메시지 삭제
|
|
163
|
+
if (await handleSecretDetection(ctx, userMessage)) {
|
|
164
|
+
return; // 메시지 삭제됨, 처리 중단
|
|
165
|
+
}
|
|
166
|
+
// 이전 요청 취소하고 새 controller 생성 (race condition 방지)
|
|
167
|
+
const controller = getNewAbortController(chatId);
|
|
75
168
|
await runWithChatId(chatId, async () => {
|
|
76
169
|
// Heartbeat 마지막 대화 시간 업데이트
|
|
77
170
|
updateLastMessageTime(chatId);
|
|
@@ -99,7 +192,12 @@ export function registerMessageHandlers(bot) {
|
|
|
99
192
|
history.push({ role: "user", content: enrichedMessage });
|
|
100
193
|
try {
|
|
101
194
|
const systemPrompt = await buildSystemPrompt(modelId);
|
|
102
|
-
const response = await chat(history, systemPrompt, modelId);
|
|
195
|
+
const response = await chat(history, systemPrompt, modelId, { signal: controller.signal });
|
|
196
|
+
// abort된 경우 히스토리 롤백
|
|
197
|
+
if (controller.signal.aborted) {
|
|
198
|
+
history.pop();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
103
201
|
history.push({ role: "assistant", content: response });
|
|
104
202
|
// 히스토리 제한 (최근 20개 메시지만 유지)
|
|
105
203
|
if (history.length > 20) {
|
|
@@ -108,9 +206,21 @@ export function registerMessageHandlers(bot) {
|
|
|
108
206
|
await ctx.reply(response);
|
|
109
207
|
}
|
|
110
208
|
catch (error) {
|
|
209
|
+
// abort로 인한 에러는 무시
|
|
210
|
+
if (controller.signal.aborted) {
|
|
211
|
+
console.log(`[Chat] Request aborted for chat ${chatId}`);
|
|
212
|
+
// 유저 메시지 롤백 (이미 추가된 경우)
|
|
213
|
+
if (history.length > 0 && history[history.length - 1].role === "user") {
|
|
214
|
+
history.pop();
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
111
218
|
console.error("Chat error:", error);
|
|
112
219
|
await ctx.reply("뭔가 잘못됐어. 다시 시도해줄래?");
|
|
113
220
|
}
|
|
221
|
+
finally {
|
|
222
|
+
cleanupAbortController(chatId, controller);
|
|
223
|
+
}
|
|
114
224
|
});
|
|
115
225
|
});
|
|
116
226
|
}
|
|
@@ -4,3 +4,5 @@ export { extractUrls, fetchWebContent, isSafeUrl } from "./url.js";
|
|
|
4
4
|
export { buildSystemPrompt, extractName } from "./prompt.js";
|
|
5
5
|
// Cache utilities
|
|
6
6
|
export { getWorkspace, invalidateWorkspaceCache } from "./cache.js";
|
|
7
|
+
// Secret detection utilities
|
|
8
|
+
export { detectSecrets, containsSecret } from "./secrets.js";
|
|
@@ -53,18 +53,21 @@ export async function buildSystemPrompt(modelId) {
|
|
|
53
53
|
parts.push("---");
|
|
54
54
|
parts.push(workspace.agents);
|
|
55
55
|
}
|
|
56
|
-
//
|
|
56
|
+
// 장기 기억 (MEMORY.md) - 먼저 로드하여 중요한 맥락 제공
|
|
57
|
+
if (workspace.memory) {
|
|
58
|
+
parts.push("---");
|
|
59
|
+
parts.push("# 장기 기억 (MEMORY.md)");
|
|
60
|
+
parts.push("이것은 사용자와의 관계, 중요한 정보, 누적된 맥락입니다. 대화할 때 항상 참고하세요.");
|
|
61
|
+
parts.push(workspace.memory);
|
|
62
|
+
}
|
|
63
|
+
// 최근 일일 기억 로드 (오늘과 어제 우선)
|
|
57
64
|
const recentMemories = await loadRecentMemories(3);
|
|
58
65
|
if (recentMemories.trim()) {
|
|
59
66
|
parts.push("---");
|
|
60
|
-
parts.push("# 최근
|
|
67
|
+
parts.push("# 최근 일일 기록");
|
|
68
|
+
parts.push("최근 대화와 활동 기록입니다. '오늘'과 '어제'의 맥락을 파악하는 데 활용하세요.");
|
|
61
69
|
parts.push(recentMemories);
|
|
62
70
|
}
|
|
63
|
-
if (workspace.memory) {
|
|
64
|
-
parts.push("---");
|
|
65
|
-
parts.push("# 장기 기억");
|
|
66
|
-
parts.push(workspace.memory);
|
|
67
|
-
}
|
|
68
71
|
}
|
|
69
72
|
// 도구 설명
|
|
70
73
|
parts.push("---");
|