diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index 66b9b9cd..74c48a43 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -1,79 +1,44 @@ -# Build stage -FROM node:22-alpine AS builder - -# Install pnpm +# Stage 1: install dependencies (cached layer) +FROM node:22-alpine AS deps RUN corepack enable && corepack prepare pnpm@10.28.2 --activate - WORKDIR /app -# Copy package files (pnpm-workspace.yaml needed for catalog resolution) -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# Copy all workspace package.json files (pnpm --frozen-lockfile needs the full workspace structure) +COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/cli/package.json ./apps/cli/ +COPY apps/desktop/package.json ./apps/desktop/ +COPY apps/gateway/package.json ./apps/gateway/ +COPY apps/mobile/package.json ./apps/mobile/ +COPY apps/server/package.json ./apps/server/ +COPY apps/web/package.json ./apps/web/ +COPY packages/core/package.json ./packages/core/ +COPY packages/hooks/package.json ./packages/hooks/ COPY packages/sdk/package.json ./packages/sdk/ COPY packages/store/package.json ./packages/store/ +COPY packages/types/package.json ./packages/types/ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/utils/package.json ./packages/utils/ -# Install all dependencies (including devDependencies for build) -RUN pnpm install --frozen-lockfile +RUN pnpm install --frozen-lockfile --filter @multica/gateway... -# Copy workspace packages and bundle them with esbuild (resolves extensionless imports) +# Stage 2: runtime +FROM node:22-alpine +RUN corepack enable && corepack prepare pnpm@10.28.2 --activate +WORKDIR /app + +# Copy pnpm structure + node_modules (includes hoisted deps and workspace symlinks) +COPY --from=deps /app ./ + +# Copy workspace packages needed at runtime (raw .ts source, resolved by tsx) COPY packages/sdk/ ./packages/sdk/ COPY packages/store/ ./packages/store/ -RUN ./node_modules/.bin/esbuild packages/sdk/src/index.ts \ - --bundle --platform=node --format=esm --outfile=packages/sdk/dist/index.js --packages=external && \ - ./node_modules/.bin/esbuild packages/store/src/index.ts packages/store/src/connection.ts \ - --bundle --splitting --platform=node --format=esm --outdir=packages/store/dist --packages=external -# Copy source code -COPY tsconfig.json ./ -COPY src ./src +# Copy gateway source + static assets +COPY apps/gateway/ ./apps/gateway/ -# Build TypeScript (tsc emits JS despite type errors; ignore exit code) -RUN ./node_modules/.bin/tsc || true - -# Production stage -FROM node:22-alpine AS production - -# Install pnpm -RUN corepack enable && corepack prepare pnpm@10.28.2 --activate - -WORKDIR /app - -# Copy package files (pnpm-workspace.yaml needed for catalog resolution) -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY packages/sdk/package.json ./packages/sdk/ -COPY packages/store/package.json ./packages/store/ - -# Install production dependencies only -RUN pnpm install --frozen-lockfile --prod - -# Copy built workspace packages and patch exports to point to compiled JS -COPY --from=builder /app/packages/sdk/dist ./packages/sdk/dist -COPY --from=builder /app/packages/store/dist ./packages/store/dist -RUN node -e " \ - const fs = require('fs'); \ - ['packages/sdk/package.json', 'packages/store/package.json'].forEach(p => { \ - const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); \ - if (pkg.exports) { \ - for (const [key, val] of Object.entries(pkg.exports)) { \ - if (typeof val === 'string') { \ - pkg.exports[key] = val.replace('./src/', './dist/').replace('.ts', '.js'); \ - } \ - } \ - } \ - fs.writeFileSync(p, JSON.stringify(pkg, null, 2)); \ - });" - -# Copy built files from builder stage -COPY --from=builder /app/dist ./dist - -# Copy static assets (not emitted by tsc) -COPY --from=builder /app/src/gateway/public ./dist/gateway/public - -# Set environment variables ENV NODE_ENV=production ENV PORT=3000 - -# Expose the port EXPOSE 3000 -# Run the gateway -CMD ["node", "dist/gateway/main.js"] +WORKDIR /app/apps/gateway +CMD ["node", "--import", "tsx", "main.ts"] diff --git a/apps/gateway/scripts/build-and-push.sh b/apps/gateway/scripts/build-and-push.sh index 19731f42..051b7c48 100755 --- a/apps/gateway/scripts/build-and-push.sh +++ b/apps/gateway/scripts/build-and-push.sh @@ -10,6 +10,7 @@ REPO="085931705009.dkr.ecr.us-west-2.amazonaws.com/super-multica/gateway" BRANCH="$(git symbolic-ref --short -q HEAD | tr '/' '-')" IMAGE_TAG="$(date +%F_%H-%M-%S)-${BRANCH}-$(git rev-parse --short HEAD)" IMAGE="$REPO:$IMAGE_TAG" +IMAGE_LATEST="$REPO:latest" # Determine if sudo is needed for docker commands if [[ "$(uname -s)" == "Linux" ]]; then @@ -21,13 +22,27 @@ fi echo "Building image: $IMAGE" echo "Using Dockerfile: $GATEWAY_DIR/Dockerfile" echo "Build context: $PROJECT_ROOT" +echo "" # Login to ECR aws ecr get-login-password --region us-west-2 | $DOCKER_CMD login --username AWS --password-stdin 085931705009.dkr.ecr.us-west-2.amazonaws.com # Build from project root with gateway Dockerfile -$DOCKER_CMD build -f "$GATEWAY_DIR/Dockerfile" -t "$IMAGE" "$PROJECT_ROOT" +START_TIME=$(date +%s) +$DOCKER_CMD build \ + -f "$GATEWAY_DIR/Dockerfile" \ + -t "$IMAGE" \ + -t "$IMAGE_LATEST" \ + "$PROJECT_ROOT" +END_TIME=$(date +%s) +echo "" +echo "Build completed in $((END_TIME - START_TIME))s" + +# Push both tags $DOCKER_CMD push "$IMAGE" +$DOCKER_CMD push "$IMAGE_LATEST" echo "" -echo "Successfully pushed: $IMAGE" +echo "Successfully pushed:" +echo " $IMAGE" +echo " $IMAGE_LATEST" diff --git a/apps/gateway/telegram/telegram.service.ts b/apps/gateway/telegram/telegram.service.ts index 66e4742b..487738e0 100644 --- a/apps/gateway/telegram/telegram.service.ts +++ b/apps/gateway/telegram/telegram.service.ts @@ -53,8 +53,10 @@ const VERIFY_TIMEOUT_MS = 30_000; @Injectable() export class TelegramService implements OnModuleInit { + private static readonly TYPING_TIMEOUT_MS = 60_000; // 1 minute safety cap private bot: Bot | null = null; private pendingRequests = new Map(); + private typingTimers = new Map>(); private readonly logger = new Logger(TelegramService.name); @@ -351,12 +353,21 @@ export class TelegramService implements OnModuleInit { return; } - // Stream event — extract text content for Telegram + // Stream event — typing indicator + extract text content for Telegram if (msg.action === StreamAction) { const streamPayload = msg.payload as StreamPayload; const event = streamPayload?.event; - if (event && "type" in event && event.type === "message_end") { - // Extract final text from the message + if (!event || !("type" in event)) return; + + // Start typing when LLM begins generating + if (event.type === "message_start") { + this.startTyping(telegramUserId); + return; + } + + // Stop typing + send text on message_end + if (event.type === "message_end") { + this.stopTyping(telegramUserId); const agentMsg = (event as { message?: { content?: Array<{ type: string; text?: string }> } }).message; if (agentMsg?.content) { const textContent = agentMsg.content @@ -367,7 +378,15 @@ export class TelegramService implements OnModuleInit { this.sendToTelegram(deviceId, textContent); } } + return; } + + // Stop typing on error + if (event.type === "agent_error") { + this.stopTyping(telegramUserId); + return; + } + return; } @@ -382,6 +401,7 @@ export class TelegramService implements OnModuleInit { // Error messages if (msg.action === "error") { + this.stopTyping(telegramUserId); const payload = msg.payload as { message?: string; code?: string }; if (payload?.message) { this.sendToTelegram(deviceId, `Error: ${payload.message}`); @@ -391,6 +411,34 @@ export class TelegramService implements OnModuleInit { }); } + /** Start sending "typing" indicator to Telegram at regular intervals */ + private startTyping(telegramUserId: string): void { + if (this.typingTimers.has(telegramUserId)) return; + const chatId = Number(telegramUserId); + const send = () => { + void this.bot?.api.sendChatAction(chatId, "typing").catch(() => {}); + }; + send(); + const interval = setInterval(send, 5000); + this.typingTimers.set(telegramUserId, interval); + + // Safety timeout: auto-stop if no message_end/agent_error arrives + setTimeout(() => { + if (this.typingTimers.get(telegramUserId) === interval) { + this.stopTyping(telegramUserId); + } + }, TelegramService.TYPING_TIMEOUT_MS); + } + + /** Stop the "typing" indicator for a Telegram user */ + private stopTyping(telegramUserId: string): void { + const timer = this.typingTimers.get(telegramUserId); + if (timer) { + clearInterval(timer); + this.typingTimers.delete(telegramUserId); + } + } + /** Cleanup all pending requests (used on verify failure) */ private cleanupPendingRequests(): void { for (const [id, pending] of this.pendingRequests) {