feat(bot/extension/watcher): Discord 아티팩트 알림 개선 — 파일 첨부 전송, truncation 확대, 동적 .md 감시

This commit is contained in:
Variet Worker
2026-03-13 09:46:56 +09:00
parent 9036f1cefc
commit e5a05e3ac4
10 changed files with 266 additions and 57 deletions

120
bot.py
View File

@@ -491,34 +491,49 @@ class GravityBot(commands.Bot):
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
full_content = event.content.strip()
CHUNK_SIZE = 4000 # Discord embed desc limit is 4096
if not full_content:
full_content = "(빈 파일)"
# Split into chunks for long content
chunks = []
while full_content:
chunks.append(full_content[:CHUNK_SIZE])
full_content = full_content[CHUNK_SIZE:]
FILE_ATTACH_THRESHOLD = 4000 # Above this, send as file attachment
if not chunks:
chunks = ["(빈 파일)"]
if len(full_content) > FILE_ATTACH_THRESHOLD:
# Long content → summary embed + file attachment
# Extract first meaningful paragraph for summary
summary_lines = []
for line in full_content.split('\n'):
if line.strip():
summary_lines.append(line.strip())
if len('\n'.join(summary_lines)) > 300:
break
summary = '\n'.join(summary_lines[:5])
if len(summary) > 500:
summary = summary[:500] + '...'
# First chunk with title
embed = discord.Embed(
title=f"{label} ({event_label}됨)",
description=chunks[0],
description=f"{summary}\n\n📎 *전체 내용은 첨부 파일을 확인하세요* ({len(full_content):,}자)",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
await channel.send(embed=embed)
# Additional chunks if content is long
for i, chunk in enumerate(chunks[1:], 2):
embed = discord.Embed(
title=f"{label} (계속 {i}/{len(chunks)})",
description=chunk,
color=discord.Color.blue(),
# Create in-memory file attachment
import io
file_bytes = full_content.encode('utf-8')
discord_file = discord.File(
io.BytesIO(file_bytes),
filename=event.file_name,
)
await channel.send(embed=embed, file=discord_file)
else:
# Short content → inline embed (original behavior)
embed = discord.Embed(
title=f"{label} ({event_label}됨)",
description=full_content,
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
await channel.send(embed=embed)
# ─── Approval Scanner ────────────────────────────────────────────
@@ -823,31 +838,80 @@ class GravityBot(commands.Bot):
data = json.loads(f.read_text(encoding="utf-8-sig"))
project = data.get("project_name", Config.PROJECT_NAME)
content = data.get("content", "")
attached_files = data.get("attached_files", [])
if content:
if content or attached_files:
channel = await self._get_channel(project)
if channel:
# Split long content
CHUNK = 4000
chunks = [content[i:i+CHUNK] for i in range(0, len(content), CHUNK)]
for i, chunk in enumerate(chunks):
title = "💬 AI 대화 내용" if i == 0 else f"💬 (계속 {i+1}/{len(chunks)})"
import io
# ── Send attached files (from Extension's writeChatSnapshotWithFiles) ──
discord_files = []
for af in attached_files:
af_name = af.get("name", "document.md")
af_content = af.get("content", "")
if af_content:
discord_files.append(discord.File(
io.BytesIO(af_content.encode("utf-8")),
filename=af_name,
))
FILE_ATTACH_THRESHOLD = 4000
if len(content) > FILE_ATTACH_THRESHOLD:
# Long chat content → summary embed + file attachment
summary = content[:500].rsplit('\n', 1)[0]
embed = discord.Embed(
title=title,
description=chunk,
title="💬 AI 대화 내용",
description=f"{summary}\n\n📎 *전체 내용은 첨부 파일 참조* ({len(content):,}자)",
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
# Add content itself as file attachment
discord_files.append(discord.File(
io.BytesIO(content.encode("utf-8")),
filename="chat_message.md",
))
try:
await channel.send(embed=embed, files=discord_files)
except discord.NotFound:
logger.warning(f"Channel deleted for {project}, re-creating...")
self.project_channels.pop(project, None)
channel = await self._get_channel(project)
if channel:
# Re-create files (discord.File consumed after send)
discord_files2 = []
for af in attached_files:
af_name = af.get("name", "document.md")
af_content = af.get("content", "")
if af_content:
discord_files2.append(discord.File(
io.BytesIO(af_content.encode("utf-8")),
filename=af_name,
))
discord_files2.append(discord.File(
io.BytesIO(content.encode("utf-8")),
filename="chat_message.md",
))
await channel.send(embed=embed, files=discord_files2)
else:
# Short content → inline embed (original)
embed = discord.Embed(
title="💬 AI 대화 내용",
description=content,
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
try:
await channel.send(embed=embed)
await channel.send(
embed=embed,
files=discord_files if discord_files else discord.utils.MISSING,
)
except discord.NotFound:
# Channel was deleted — invalidate cache and retry once
logger.warning(f"Channel deleted for {project}, re-creating...")
self.project_channels.pop(project, None)
channel = await self._get_channel(project)
if channel:
await channel.send(embed=embed)
break
f.unlink() # Cleanup
except (json.JSONDecodeError, OSError) as e:

View File

@@ -333,10 +333,11 @@ class RemoteTransport(BridgeTransport):
"conversation_id": conv_id, "project_name": project,
})
async def asend_chat(self, project: str, content: str) -> None:
await self._arequest_retry("POST", "/api/chat", {
"project_name": project, "content": content,
})
async def asend_chat(self, project: str, content: str, *, attached_files: list[dict] | None = None) -> None:
payload: dict = {"project_name": project, "content": content}
if attached_files:
payload["attached_files"] = attached_files
await self._arequest_retry("POST", "/api/chat", payload)
async def asend_event(self, event_data: dict) -> None:
await self._arequest_retry("POST", "/api/event", event_data)

View File

@@ -308,9 +308,11 @@ class CollectorBridge:
data = json.loads(f.read_text(encoding="utf-8-sig"))
project = data.get("project_name", self.project_name)
content = data.get("content", "")
if content:
await self.remote.asend_chat(project, content)
logger.info(f"[COLLECTOR] → Gateway: chat snapshot len={len(content)}")
attached_files = data.get("attached_files", [])
if content or attached_files:
await self.remote.asend_chat(project, content, attached_files=attached_files)
af_info = f" +{len(attached_files)} files" if attached_files else ""
logger.info(f"[COLLECTOR] → Gateway: chat snapshot len={len(content)}{af_info}")
f.unlink() # Cleanup after forwarding
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"[COLLECTOR] bad chat snapshot {f.name}: {e}")

View File

@@ -32,6 +32,9 @@ class Config:
"walkthrough.md",
}
# Extension-based monitoring: any file with these extensions in brain/{conv}/ is watched
WATCHED_EXTENSIONS: set = {".md"}
# Discord message limits
DISCORD_MSG_LIMIT: int = 2000
DISCORD_EMBED_DESC_LIMIT: int = 4096

View File

@@ -0,0 +1,5 @@
# Devlog — 2026-03-13
| # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------|
| 001 | 08:56 | Discord 아티팩트 알림 개선 — truncation 확대, 파일 첨부 전송, 동적 .md 감시 | `pending` | ✅ |

View File

@@ -149,6 +149,31 @@ function writeChatSnapshot(text) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
}
}
function writeChatSnapshotWithFiles(text, files) {
try {
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) {
fs.mkdirSync(snapshotDir, { recursive: true });
}
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
attached_files: files,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`);
if (activeSessionId) {
writeRegistration(activeSessionId);
}
}
catch (e) {
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
}
}
// ─── Command File Watcher (Discord → Antigravity) ───
function processCommandFile(filePath) {
try {
@@ -1821,8 +1846,8 @@ function setupMonitor() {
if (text.length > 10) {
lastResponseCaptureStep = actualIdx;
logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
const truncated = text.length > 1800
? text.substring(0, 1800) + '\n\n_(이하 생략)_'
const truncated = text.length > 3500
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
: text;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
@@ -2129,7 +2154,12 @@ function setupMonitor() {
if (notifyStep) {
if (notifyStep.stepIndex > lastNotifyStepIndex) {
lastNotifyStepIndex = notifyStep.stepIndex;
const content = notifyStep.step?.notifyUser?.notificationContent || '';
const notifyData = notifyStep.step?.notifyUser || {};
const content = notifyData.notificationContent || '';
// Log full structure once for schema discovery
if (pollCount <= 3 || notifyStep.stepIndex <= lastNotifyStepIndex + 1) {
logToFile(`[NOTIFY-STEP] keys=[${Object.keys(notifyData).join(',')}]`);
}
logToFile(`[NOTIFY-STEP] NEW step=${notifyStep.stepIndex} content=${content.length} chars`);
// Filter: relay all non-empty notifications
if (content.length > 10) {
@@ -2138,6 +2168,35 @@ function setupMonitor() {
else if (content.length > 0) {
logToFile(`[NOTIFY-STEP] skipped (too short: ${content.length} chars): ${content}`);
}
// ── PathsToReview: read and relay referenced artifact files ──
const pathsToReview = notifyData.pathsToReview
|| notifyData.paths_to_review
|| notifyData.filePaths
|| [];
if (pathsToReview.length > 0) {
logToFile(`[NOTIFY-STEP] PathsToReview: ${pathsToReview.length} files`);
for (const filePath of pathsToReview.slice(0, 5)) {
try {
if (fs.existsSync(filePath)) {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const fileName = path.basename(filePath);
const MAX_ARTIFACT_SIZE = 8000;
const truncatedContent = fileContent.length > MAX_ARTIFACT_SIZE
? fileContent.substring(0, MAX_ARTIFACT_SIZE) + '\n\n_(이하 생략)_'
: fileContent;
// Write as snapshot with attached_files for bot to send as Discord file
writeChatSnapshotWithFiles(`📎 **문서: ${fileName}** (${Math.round(fileContent.length / 1024)}KB)`, [{ name: fileName, content: truncatedContent }]);
logToFile(`[NOTIFY-STEP] relayed artifact: ${fileName} (${fileContent.length} chars)`);
}
else {
logToFile(`[NOTIFY-STEP] artifact not found: ${filePath}`);
}
}
catch (e) {
logToFile(`[NOTIFY-STEP] artifact read error: ${e.message}`);
}
}
}
}
}
else if (pollCount <= 5) {
@@ -2277,8 +2336,8 @@ function setupMonitor() {
textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput);
if (textContent.length > 10) {
logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`);
const truncated = textContent.length > 1800
? textContent.substring(0, 1800) + '\n\n_(이하 생략)_'
const truncated = textContent.length > 3500
? textContent.substring(0, 3500) + '\n\n_(이하 생략)_'
: textContent;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;

File diff suppressed because one or more lines are too long

View File

@@ -113,6 +113,27 @@ function writeChatSnapshot(text: string) {
}
}
function writeChatSnapshotWithFiles(text: string, files: Array<{name: string, content: string}>) {
try {
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
attached_files: files,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
} catch (e: any) {
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
}
}
// ─── Command File Watcher (Discord → Antigravity) ───
@@ -1823,8 +1844,8 @@ function setupMonitor() {
if (text.length > 10) {
lastResponseCaptureStep = actualIdx;
logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
const truncated = text.length > 1800
? text.substring(0, 1800) + '\n\n_(이하 생략)_'
const truncated = text.length > 3500
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
: text;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
@@ -2116,7 +2137,12 @@ function setupMonitor() {
if (notifyStep) {
if (notifyStep.stepIndex > lastNotifyStepIndex) {
lastNotifyStepIndex = notifyStep.stepIndex;
const content = notifyStep.step?.notifyUser?.notificationContent || '';
const notifyData = notifyStep.step?.notifyUser || {} as any;
const content = notifyData.notificationContent || '';
// Log full structure once for schema discovery
if (pollCount <= 3 || notifyStep.stepIndex <= lastNotifyStepIndex + 1) {
logToFile(`[NOTIFY-STEP] keys=[${Object.keys(notifyData).join(',')}]`);
}
logToFile(`[NOTIFY-STEP] NEW step=${notifyStep.stepIndex} content=${content.length} chars`);
// Filter: relay all non-empty notifications
if (content.length > 10) {
@@ -2124,6 +2150,37 @@ function setupMonitor() {
} else if (content.length > 0) {
logToFile(`[NOTIFY-STEP] skipped (too short: ${content.length} chars): ${content}`);
}
// ── PathsToReview: read and relay referenced artifact files ──
const pathsToReview: string[] = notifyData.pathsToReview
|| notifyData.paths_to_review
|| notifyData.filePaths
|| [];
if (pathsToReview.length > 0) {
logToFile(`[NOTIFY-STEP] PathsToReview: ${pathsToReview.length} files`);
for (const filePath of pathsToReview.slice(0, 5)) {
try {
if (fs.existsSync(filePath)) {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const fileName = path.basename(filePath);
const MAX_ARTIFACT_SIZE = 8000;
const truncatedContent = fileContent.length > MAX_ARTIFACT_SIZE
? fileContent.substring(0, MAX_ARTIFACT_SIZE) + '\n\n_(이하 생략)_'
: fileContent;
// Write as snapshot with attached_files for bot to send as Discord file
writeChatSnapshotWithFiles(
`📎 **문서: ${fileName}** (${Math.round(fileContent.length/1024)}KB)`,
[{ name: fileName, content: truncatedContent }]
);
logToFile(`[NOTIFY-STEP] relayed artifact: ${fileName} (${fileContent.length} chars)`);
} else {
logToFile(`[NOTIFY-STEP] artifact not found: ${filePath}`);
}
} catch (e: any) {
logToFile(`[NOTIFY-STEP] artifact read error: ${e.message}`);
}
}
}
}
} else if (pollCount <= 5) {
logToFile(`[NOTIFY-STEP] null (no notify step in session)`);
@@ -2251,8 +2308,8 @@ function setupMonitor() {
if (!textContent && s?.rawOutput) textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput);
if (textContent.length > 10) {
logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`);
const truncated = textContent.length > 1800
? textContent.substring(0, 1800) + '\n\n_(이하 생략)_'
const truncated = textContent.length > 3500
? textContent.substring(0, 3500) + '\n\n_(이하 생략)_'
: textContent;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;

View File

@@ -157,9 +157,10 @@ class GatewayAPI:
data = await request.json()
project = data.get("project_name", "")
content = data.get("content", "")
attached_files = data.get("attached_files", [])
if not project or not content:
return web.json_response({"ok": False, "error": "project_name and content required"}, status=400)
if not project or (not content and not attached_files):
return web.json_response({"ok": False, "error": "project_name and content/attached_files required"}, status=400)
# Write to chat_snapshots dir for bot's scanner
snap_dir = self.bot.bridge.transport.bridge_dir / "chat_snapshots" if hasattr(self.bot.bridge.transport, 'bridge_dir') else None
@@ -172,12 +173,15 @@ class GatewayAPI:
"content": content,
"timestamp": time.time(),
}
if attached_files:
snap_data["attached_files"] = attached_files
(snap_dir / f"{snap_id}.json").write_text(
json.dumps(snap_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(f"[GATEWAY] chat received: project={project} len={len(content)}")
af_info = f" +{len(attached_files)} files" if attached_files else ""
logger.info(f"[GATEWAY] chat received: project={project} len={len(content)}{af_info}")
return web.json_response({"ok": True})
except Exception as e:
logger.error(f"[GATEWAY] chat error: {e}")

View File

@@ -110,8 +110,14 @@ class BrainEventHandler(FileSystemEventHandler):
return True
def _is_watched_file(self, file_name: str) -> bool:
"""Strict filter: only watch primary artifact files."""
return file_name in Config.WATCHED_FILES
"""Filter: watch primary artifact files + any file matching watched extensions."""
if file_name in Config.WATCHED_FILES:
return True
# Extension-based matching (e.g., any .md file in conversation dir)
ext = Path(file_name).suffix
if ext and ext in Config.WATCHED_EXTENSIONS:
return True
return False
def _emit(self, event: BrainEvent):
self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event)
@@ -142,9 +148,17 @@ class BrainEventHandler(FileSystemEventHandler):
if not conv_id:
return
# Exclude files in .system_generated subdirectory (AG internal logs)
try:
relative = path.relative_to(Config.BRAIN_PATH / conv_id)
if '.system_generated' in relative.parts:
return
except ValueError:
pass
file_name = path.name
# STRICT filter: only primary artifacts
# Filter: watched files by name or extension
if not self._is_watched_file(file_name):
return