Merge pull request #133 from multica-ai/fix/gateway-dockerfile

fix(gateway): rewrite Dockerfile and add Telegram typing indicator
This commit is contained in:
LinYushen 2026-02-12 13:34:30 +08:00 committed by GitHub
commit 5c3fd3ea06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 69 deletions

View file

@ -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"]

View file

@ -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"

View file

@ -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<string, PendingRequest>();
private typingTimers = new Map<string, ReturnType<typeof setInterval>>();
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) {