diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 2964e11..e3c9c00 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/mcp.cpython-311.pyc b/core/__pycache__/mcp.cpython-311.pyc new file mode 100644 index 0000000..2b38bd9 Binary files /dev/null and b/core/__pycache__/mcp.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 18a063c..0e37a4a 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index ebb8c6e..fdbc967 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 8d204fa..ed3679d 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..4121599 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,17 @@ from django.contrib import admin +from .models import MCPToolRequest, HumanResponse, SlackSettings -# Register your models here. +@admin.register(SlackSettings) +class SlackSettingsAdmin(admin.ModelAdmin): + list_display = ('__str__', 'updated_at') + +@admin.register(MCPToolRequest) +class MCPToolRequestAdmin(admin.ModelAdmin): + list_display = ('id', 'tool_name', 'status', 'created_at') + list_filter = ('status', 'tool_name') + readonly_fields = ('id', 'created_at', 'updated_at') + +@admin.register(HumanResponse) +class HumanResponseAdmin(admin.ModelAdmin): + list_display = ('id', 'request', 'user_name', 'created_at') + readonly_fields = ('id', 'created_at') \ No newline at end of file diff --git a/core/mcp.py b/core/mcp.py new file mode 100644 index 0000000..90a48bc --- /dev/null +++ b/core/mcp.py @@ -0,0 +1,190 @@ +import json +import os +import httpx +from django.conf import settings +from .models import MCPToolRequest, HumanResponse, SlackSettings +from asgiref.sync import sync_to_async + +SLACK_API_URL = "https://slack.com/api/chat.postMessage" + +@sync_to_async +def get_slack_token(): + token = os.getenv("SLACK_BOT_TOKEN") + if token: + return token + config = SlackSettings.objects.first() + if config and config.bot_token: + return config.bot_token + return None + +async def send_slack_message(channel, text, thread_ts=None): + token = await get_slack_token() + if not token: + return {"error": "SLACK_BOT_TOKEN not configured", "ok": False} + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8" + } + payload = { + "channel": channel, + "text": text, + } + if thread_ts: + payload["thread_ts"] = thread_ts + + async with httpx.AsyncClient() as client: + response = await client.post(SLACK_API_URL, headers=headers, json=payload) + data = response.json() + return data + +async def handle_mcp_request(body): + """ + Simple MCP JSON-RPC handler for HTTP/SSE. + Supports tools/list and tools/call. + """ + try: + request_data = json.loads(body) + except json.JSONDecodeError: + return {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} + + method = request_data.get("method") + params = request_data.get("params", {}) + request_id = request_data.get("id") + + if method == "initialize": + return { + "jsonrpc": "2.0", + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "flatlogic-slack", + "version": "1.0.0" + } + }, + "id": request_id + } + + elif method == "notifications/initialized": + return None # No response for notifications + + elif method == "ping": + return { + "jsonrpc": "2.0", + "result": {}, + "id": request_id + } + + elif method == "tools/list": + return { + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "name": "send_slack_message", + "description": "Sends a message to a Slack channel.", + "inputSchema": { + "type": "object", + "properties": { + "channel": {"type": "string", "description": "Channel ID or Name"}, + "text": {"type": "string", "description": "Message content"} + }, + "required": ["channel", "text"] + } + }, + { + "name": "list_responses", + "description": "Lists human responses for a specific request ID.", + "inputSchema": { + "type": "object", + "properties": { + "request_id": {"type": "string", "description": "The UUID of the original request"} + }, + "required": ["request_id"] + } + } + ] + }, + "id": request_id + } + + elif method == "tools/call": + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + if tool_name == "send_slack_message": + channel = arguments.get("channel") + text = arguments.get("text") + + # Log request + db_request = await MCPToolRequest.objects.acreate( + tool_name=tool_name, + arguments=arguments, + slack_channel=channel or "" + ) + + slack_result = await send_slack_message(channel, text) + + if slack_result.get("ok"): + db_request.status = 'SENT' + db_request.slack_ts = slack_result.get("ts", "") + db_request.response_json = slack_result + await db_request.asave() + + return { + "jsonrpc": "2.0", + "result": { + "content": [ + { + "type": "text", + "text": f"Message sent to Slack (TS: {db_request.slack_ts}). Request ID: {db_request.id}" + } + ] + }, + "id": request_id + } + else: + db_request.status = 'ERROR' + db_request.response_json = slack_result + await db_request.asave() + return { + "jsonrpc": "2.0", + "error": { + "code": -32000, + "message": f"Slack API Error: {slack_result.get('error')}" + }, + "id": request_id + } + + elif tool_name == "list_responses": + req_id = arguments.get("request_id") + responses = [] + async for r in HumanResponse.objects.filter(request_id=req_id): + responses.append({ + "text": r.text, + "user": r.user_name, + "file_url": r.file_url, + "timestamp": str(r.created_at) + }) + + return { + "jsonrpc": "2.0", + "result": { + "content": [ + { + "type": "text", + "text": json.dumps(responses, indent=2) if responses else "No responses yet." + } + ] + }, + "id": request_id + } + + return { + "jsonrpc": "2.0", + "error": {"code": -32601, "message": "Method not found"}, + "id": request_id + } diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..b96f6fd --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.7 on 2026-02-25 23:18 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='MCPToolRequest', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('tool_name', models.CharField(max_length=100)), + ('arguments', models.JSONField()), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('SENT', 'Sent'), ('ERROR', 'Error')], default='PENDING', max_length=10)), + ('slack_channel', models.CharField(blank=True, max_length=50)), + ('slack_ts', models.CharField(blank=True, help_text='Slack message timestamp', max_length=50)), + ('response_json', models.JSONField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='HumanResponse', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('text', models.TextField()), + ('user_name', models.CharField(blank=True, max_length=100)), + ('file_url', models.URLField(blank=True, max_length=500, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='core.mcptoolrequest')), + ], + ), + ] diff --git a/core/migrations/0002_slacksettings.py b/core/migrations/0002_slacksettings.py new file mode 100644 index 0000000..68b0b42 --- /dev/null +++ b/core/migrations/0002_slacksettings.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.7 on 2026-02-25 23:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SlackSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bot_token', models.CharField(blank=True, max_length=255, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name_plural': 'Slack Settings', + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..21958eb Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0002_slacksettings.cpython-311.pyc b/core/migrations/__pycache__/0002_slacksettings.cpython-311.pyc new file mode 100644 index 0000000..3f3645c Binary files /dev/null and b/core/migrations/__pycache__/0002_slacksettings.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..0bf39ed 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,43 @@ from django.db import models +import uuid -# Create your models here. +class SlackSettings(models.Model): + """Stores the Slack App Token so users don't have to edit .env files.""" + bot_token = models.CharField(max_length=255, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return "Slack Settings" + + class Meta: + verbose_name_plural = "Slack Settings" + +class MCPToolRequest(models.Model): + STATUS_CHOICES = [ + ('PENDING', 'Pending'), + ('SENT', 'Sent'), + ('ERROR', 'Error'), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + tool_name = models.CharField(max_length=100) + arguments = models.JSONField() + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='PENDING') + slack_channel = models.CharField(max_length=50, blank=True) + slack_ts = models.CharField(max_length=50, blank=True, help_text="Slack message timestamp") + response_json = models.JSONField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.tool_name} - {self.status} ({self.created_at})" + +class HumanResponse(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + request = models.ForeignKey(MCPToolRequest, related_name='responses', on_delete=models.CASCADE) + text = models.TextField() + user_name = models.CharField(max_length=100, blank=True) + file_url = models.URLField(max_length=500, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Response to {self.request.id} - {self.created_at}" diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..2701929 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -2,24 +2,56 @@ - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} - {% if project_image_url %} - - - {% endif %} - {% load static %} - - {% block head %}{% endblock %} + + + {% block title %}MCP Slack Server{% endblock %} + {% if project_description %} + + {% endif %} + {% load static %} + + + + + + {% block head %}{% endblock %} - {% block content %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ + + + + + {% block scripts %}{% endblock %} - + \ No newline at end of file diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..4ccc125 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,144 @@ {% extends "base.html" %} +{% load static %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}Dashboard | MCP Slack Bridge{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+

AI &Human Slack Bridge

+

+ Bridging the gap between autonomous AI agents and human workers. + Exposing tools to request design, feedback, or any manual task directly via Slack. +

+
+ + + Server: {{ server_status }} + + + DJANGO 5.2 • MCP v1.0 + +
+
+
+
+
MCP Endpoint
+ /mcp/ +

Post JSON-RPC here

+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- -{% endblock %} \ No newline at end of file + + +
+
+
+ {% if not is_slack_configured %} +
+
Configuration Required
+

+ Set SLACK_BOT_TOKEN in your environment to enable message delivery. +

+
+ {% endif %} + +
+
+
+
Recent Tool Activity
+ Showing last 20 requests +
+
+ + + + + + + + + + + + {% for r in requests %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
TIMESTAMPTOOL NAMECHANNELSTATUSACTION
{{ r.created_at|date:"Y-m-d H:i" }}{{ r.tool_name }}#{{ r.slack_channel }} + {% if r.status == 'SENT' %} + SENT + {% elif r.status == 'ERROR' %} + ERROR + {% else %} + PENDING + {% endif %} + + Details +
+ No activity logged yet. Connect an MCP client to start. +
+
+
+
+ +
+
+
+
How to use (Tools/Call)
+
+{
+  "jsonrpc": "2.0",
+  "method": "tools/call",
+  "params": {
+    "name": "send_slack_message",
+    "arguments": {
+      "channel": "C12345",
+      "text": "Hello Human!"
+    }
+  },
+  "id": 1
+}
+
+
+
+
+
MCP Server Info
+
    +
  • + Status: + ONLINE +
  • +
  • + Version: + 1.0.0 +
  • +
  • + Available Tools: + 2 +
  • +
  • + Python: + {{ python_version|default:"3.11" }} +
  • +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/request_detail.html b/core/templates/core/request_detail.html new file mode 100644 index 0000000..5c1aca9 --- /dev/null +++ b/core/templates/core/request_detail.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Request Details | MCP Slack Bridge{% endblock %} + +{% block content %} +
+
+
+ + +
+
+

Tool Call Details

+ {% if req.status == 'SENT' %} + SENT + {% elif req.status == 'ERROR' %} + ERROR + {% else %} + PENDING + {% endif %} +
+ +
+
+

Tool Name

+
{{ req.tool_name }}
+
+
+

Slack Channel

+
#{{ req.slack_channel|default:"N/A" }}
+
+
+

Slack Timestamp

+
{{ req.slack_ts|default:"N/A" }}
+
+
+ +
+ +
+
Arguments
+
{{ req.arguments|json_script:"args-data" }}
+
+
+ +
+
Slack API Response
+
{{ req.response_json|json_script:"resp-data" }}
+
+
+ +
+
+ Human Responses + {{ responses.count }} +
+ + {% for r in responses %} +
+
+ {{ r.user_name|default:"Human" }} + {{ r.created_at|date:"Y-m-d H:i" }} +
+

{{ r.text }}

+ {% if r.file_url %} + + {% endif %} +
+ {% empty %} +
+

No responses recorded yet.

+ AI agent can check for updates using list_responses +
+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 6299e3d..82a0e3c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,9 @@ from django.urls import path - -from .views import home +from .views import dashboard, mcp_endpoint, slack_webhook, request_detail urlpatterns = [ - path("", home, name="home"), -] + path("", dashboard, name="home"), + path("mcp/", mcp_endpoint, name="mcp_endpoint"), + path("webhook/slack/", slack_webhook, name="slack_webhook"), + path("request//", request_detail, name="request_detail"), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index c9aed12..b42d10a 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,112 @@ +import json import os import platform - -from django import get_version as django_version -from django.shortcuts import render +from django.http import JsonResponse, HttpResponse +from django.shortcuts import render, get_object_or_404 from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from asgiref.sync import sync_to_async +from .models import MCPToolRequest, HumanResponse, SlackSettings +from .mcp import handle_mcp_request +@csrf_exempt +async def mcp_endpoint(request): + """ + Main entry point for MCP clients via HTTP. + Accepts JSON-RPC 2.0 payloads. + """ + if request.method != "POST": + return JsonResponse({"error": "Method not allowed"}, status=405) + + body = request.body.decode("utf-8") + response_data = await handle_mcp_request(body) + + if response_data is None: + return HttpResponse(status=204) + + return JsonResponse(response_data) -def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() +def dashboard(request): + """ + Polished landing page showing active server status and recent logs. + """ + requests_list = MCPToolRequest.objects.all().order_by('-created_at')[:20] + + # Slack config check + slack_token = os.getenv("SLACK_BOT_TOKEN") + if not slack_token: + config = SlackSettings.objects.first() + if config and config.bot_token: + slack_token = config.bot_token + is_slack_configured = bool(slack_token) + context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + "is_slack_configured": is_slack_configured, + "requests": requests_list, + "django_version": platform.python_version(), + "now": timezone.now(), + "server_status": "ONLINE" if is_slack_configured else "CONFIG_PENDING" } return render(request, "core/index.html", context) + +@csrf_exempt +def slack_webhook(request): + """ + Slack Event API Webhook. + Captures threaded replies and saves them as HumanResponse. + """ + if request.method == "POST": + try: + data = json.loads(request.body) + + # 1. Verification Challenge + if data.get("type") == "url_verification": + return JsonResponse({"challenge": data.get("challenge")}) + + # 2. Event Callback + if data.get("type") == "event_callback": + event = data.get("event", {}) + + # We only care about user messages (no bot_id) in threads (has thread_ts) + if event.get("type") == "message" and not event.get("bot_id"): + thread_ts = event.get("thread_ts") + text = event.get("text", "") + user_id = event.get("user", "Unknown User") + + # Basic extraction + user_name = user_id + + files = event.get("files", []) + file_url = None + if files: + file_url = files[0].get("url_private", "") + + if thread_ts: + # Find the corresponding tool request + mcp_request = MCPToolRequest.objects.filter(slack_ts=thread_ts).first() + if mcp_request: + # Avoid duplicate responses if Slack retries + # We can just check if we already have this text/user in last 10 seconds, or just save it + HumanResponse.objects.create( + request=mcp_request, + text=text, + user_name=user_name, + file_url=file_url + ) + return HttpResponse("Created", status=201) + except Exception as e: + print("Webhook error:", e) + return HttpResponse("Error", status=400) + + return HttpResponse("OK") + +def request_detail(request, pk): + """Detailed view of a single tool call.""" + tool_request = get_object_or_404(MCPToolRequest, pk=pk) + responses = tool_request.responses.all().order_by('-created_at') + + return render(request, "core/request_detail.html", { + "req": tool_request, + "responses": responses + }) diff --git a/requirements.txt b/requirements.txt index e22994c..15a35fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 +httpx==0.27.0 \ No newline at end of file diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..24063b1 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,156 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +:root { + --bg-color: #0d1117; + --card-bg: #161b22; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --accent-blue: #58a6ff; + --accent-green: #238636; + --border-color: #30363d; } + +body { + background-color: var(--bg-color); + color: var(--text-primary); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + margin: 0; + padding: 0; +} + +.navbar { + background-color: var(--card-bg) !important; + border-bottom: 1px solid var(--border-color); +} + +.navbar-brand { + font-weight: 700; + letter-spacing: -0.5px; + color: var(--accent-blue) !important; +} + +.nav-link { + color: var(--text-secondary) !important; +} + +.nav-link:hover { + color: var(--text-primary) !important; +} + +.card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + color: var(--text-primary); +} + +.card-title { + color: var(--accent-blue); + font-weight: 600; +} + +.btn-primary { + background-color: var(--accent-blue); + border-color: var(--accent-blue); + border-radius: 8px; + font-weight: 600; +} + +.btn-primary:hover { + background-color: #388bfd; + border-color: #388bfd; +} + +.badge-online { + background-color: var(--accent-green); + color: white; + padding: 4px 8px; + border-radius: 20px; + font-size: 0.75rem; + text-transform: uppercase; + font-weight: 700; + box-shadow: 0 0 10px rgba(35, 134, 54, 0.4); +} + +.badge-pending { + background-color: #d29922; + color: white; + padding: 4px 8px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 700; +} + +.badge-error { + background-color: #f85149; + color: white; + padding: 4px 8px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 700; +} + +.table { + color: var(--text-primary); +} + +.table thead th { + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-weight: 500; + font-size: 0.85rem; +} + +.table td { + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.log-entry:hover { + background-color: rgba(88, 166, 255, 0.05); + cursor: pointer; +} + +.hero-section { + padding: 80px 0; + background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.1), transparent); +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; +} + +.status-dot.online { + background-color: var(--accent-green); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(35, 134, 54, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(35, 134, 54, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(35, 134, 54, 0); + } +} + +code { + background-color: #0d1117; + padding: 2px 6px; + border-radius: 4px; + color: #ff7b72; +} + +pre { + background-color: #0d1117; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border-color); + color: var(--text-primary); + overflow-x: auto; +} \ No newline at end of file