Merge pull request #133 from multica-ai/fix/gateway-dockerfile
fix(gateway): rewrite Dockerfile and add Telegram typing indicator
This commit is contained in:
commit
5c3fd3ea06
3 changed files with 97 additions and 69 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue