Merge pull request #211 from multica-ai/codex/telegram-agent-welcome

feat(telegram): send agent-generated welcome after connect and reconnect
This commit is contained in:
Jiayuan Zhang 2026-02-16 13:22:12 +08:00 committed by GitHub
commit 292e2b9454
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 247 additions and 13 deletions

View file

@ -82,9 +82,23 @@ interface MediaAttachment {
caption?: string;
}
interface GenerateChannelWelcomeParams {
agentId: string;
channel: string;
language?: string;
firstName?: string;
isReconnect?: boolean;
}
interface GenerateChannelWelcomeResult {
text: string;
}
// ── Constants ──
const VERIFY_TIMEOUT_MS = 30_000;
const WELCOME_RPC_TIMEOUT_MS = 20_000;
const WELCOME_COOLDOWN_MS = 15_000;
const TYPING_TIMEOUT_MS = 60_000;
const MAX_CHARS_PER_MESSAGE = 4000; // Telegram limit is 4096; leave room for HTML overhead
const REPLY_CONTEXT_MAX_CHARS = 300; // Max chars of quoted text when user replies to a message
@ -170,6 +184,8 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
private typingTimers = new Map<string, ReturnType<typeof setInterval>>();
/** Tracks the originating message for reply_to & reaction cleanup, keyed by deviceId */
private messageContexts = new Map<string, MessageContext>();
/** Deduplicate welcome sends when Telegram replays updates in a short window */
private welcomeSentAt = new Map<string, number>();
private readonly logger = new Logger(TelegramService.name);
@ -1076,6 +1092,16 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
{ parse_mode: "HTML", reply_markup: successKeyboard },
);
await this.sendAgentWelcomeAfterConnect({
telegramUserId,
deviceId,
hubId: result.hubId,
agentId: result.agentId,
languageCode: msg?.from?.language_code,
firstName: msg?.from?.first_name,
isReconnect: !!existingUser,
});
this.logger.log(
`Telegram user verified: telegramUserId=${telegramUserId}, hubId=${connectionInfo.hubId}, deviceId=${deviceId}`,
);
@ -1105,20 +1131,87 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
}
}
/** Send a verify RPC to Hub via the virtual device */
private sendVerifyRpc(
private shouldSendConnectWelcome(telegramUserId: string): boolean {
const now = Date.now();
const lastSentAt = this.welcomeSentAt.get(telegramUserId);
if (lastSentAt && now - lastSentAt < WELCOME_COOLDOWN_MS) {
return false;
}
this.welcomeSentAt.set(telegramUserId, now);
return true;
}
private buildFallbackConnectWelcome(firstName: string | undefined, isReconnect: boolean): string {
const safeName = firstName?.trim() || "there";
if (isReconnect) {
return (
`Welcome back, ${safeName}. I am your Multica AI agent and I am ready again.\n\n` +
`I can help with research, drafting, and step-by-step problem solving.\n` +
`What should we work on now?`
);
}
return (
`Hi ${safeName}, I am your Multica AI agent.\n\n` +
`I can help with research, drafting, and step-by-step problem solving.\n` +
`What would you like to do first?`
);
}
private async sendAgentWelcomeAfterConnect(opts: {
telegramUserId: string;
deviceId: string;
hubId: string;
agentId: string;
languageCode?: string;
firstName?: string;
isReconnect: boolean;
}): Promise<void> {
if (!this.shouldSendConnectWelcome(opts.telegramUserId)) {
this.logger.debug(`Connect welcome skipped by cooldown: telegramUserId=${opts.telegramUserId}`);
return;
}
try {
const language = opts.languageCode?.trim().slice(0, 16) || undefined;
const firstName = opts.firstName?.trim().slice(0, 32) || undefined;
const result = await this.sendGenerateChannelWelcomeRpc(opts.deviceId, opts.hubId, {
agentId: opts.agentId,
channel: "telegram",
language,
firstName,
isReconnect: opts.isReconnect,
});
const welcomeText = result.text.trim();
if (welcomeText) {
await this.sendToTelegram(opts.deviceId, welcomeText);
return;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`generateChannelWelcome failed: deviceId=${opts.deviceId}, error=${message}`);
}
await this.sendToTelegram(
opts.deviceId,
this.buildFallbackConnectWelcome(opts.firstName, opts.isReconnect),
);
}
private sendRpc<TParams, TResult>(
deviceId: string,
hubId: string,
token: string,
meta: DeviceMeta,
): Promise<VerifyResult> {
return new Promise<VerifyResult>((resolve, reject) => {
method: string,
params: TParams,
timeoutMs: number,
timeoutMessage: string,
): Promise<TResult> {
return new Promise<TResult>((resolve, reject) => {
const requestId = uuidv7();
const timer = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error("Verify request timed out"));
}, VERIFY_TIMEOUT_MS);
reject(new Error(timeoutMessage));
}, timeoutMs);
this.pendingRequests.set(requestId, {
resolve: resolve as (v: unknown) => void,
@ -1126,13 +1219,13 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
timer,
});
const payload: RequestPayload<VerifyParams> = {
const payload: RequestPayload<TParams> = {
requestId,
method: "verify",
params: { token, meta },
method,
params,
};
const message: RoutedMessage<RequestPayload<VerifyParams>> = {
const message: RoutedMessage<RequestPayload<TParams>> = {
id: uuidv7(),
uid: null,
from: deviceId,
@ -1145,11 +1238,44 @@ export class TelegramService implements OnModuleInit, OnModuleDestroy {
if (!sent) {
this.pendingRequests.delete(requestId);
clearTimeout(timer);
reject(new Error("Failed to route verify request to Hub"));
reject(new Error(`Failed to route ${method} request to Hub`));
}
});
}
/** Send a verify RPC to Hub via the virtual device */
private sendVerifyRpc(
deviceId: string,
hubId: string,
token: string,
meta: DeviceMeta,
): Promise<VerifyResult> {
return this.sendRpc<VerifyParams, VerifyResult>(
deviceId,
hubId,
"verify",
{ token, meta },
VERIFY_TIMEOUT_MS,
"Verify request timed out",
);
}
/** Ask Hub agent to generate a proactive Telegram welcome message */
private sendGenerateChannelWelcomeRpc(
deviceId: string,
hubId: string,
params: GenerateChannelWelcomeParams,
): Promise<GenerateChannelWelcomeResult> {
return this.sendRpc<GenerateChannelWelcomeParams, GenerateChannelWelcomeResult>(
deviceId,
hubId,
"generateChannelWelcome",
params,
WELCOME_RPC_TIMEOUT_MS,
"Welcome generation timed out",
);
}
/** Route a regular chat message to the user's Hub agent */
private async routeToHub(user: TelegramUser, text: string, ctx: Context): Promise<void> {
// Ensure Hub is online

View file

@ -31,6 +31,7 @@ import { createSetHeartbeatsHandler } from "./rpc/handlers/set-heartbeats.js";
import { createWakeHeartbeatHandler } from "./rpc/handlers/wake-heartbeat.js";
import { DeviceStore, type DeviceMeta } from "./device-store.js";
import { createVerifyHandler } from "./rpc/handlers/verify.js";
import { createGenerateChannelWelcomeHandler } from "./rpc/handlers/generate-channel-welcome.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { createResolveExecApprovalHandler } from "./rpc/handlers/resolve-exec-approval.js";
import { evaluateCommandSafety, requiresApproval } from "../agent/tools/exec-safety.js";
@ -115,6 +116,7 @@ export class Hub {
return this._onConfirmDevice(deviceId, agentId, meta);
},
}));
this.rpc.register("generateChannelWelcome", createGenerateChannelWelcomeHandler(this));
this.rpc.register("getAgentMessages", createGetAgentMessagesHandler());
this.rpc.register("getHubInfo", createGetHubInfoHandler(this));
this.rpc.register("listAgents", createListAgentsHandler(this));

View file

@ -0,0 +1,105 @@
import type { RpcHandler } from "../dispatcher.js";
import { RpcError } from "../dispatcher.js";
interface GenerateChannelWelcomeParams {
agentId?: string;
channel?: string;
language?: string;
firstName?: string;
isReconnect?: boolean;
}
interface AgentLike {
runInternalForResult(content: string): Promise<{ text: string; error?: string }>;
closed?: boolean;
}
interface HubLike {
getAgent(id: string): AgentLike | undefined;
}
interface WelcomeContext {
channel: string;
language: string;
firstName: string;
isReconnect: boolean;
}
const DEFAULT_LANGUAGE = "English";
const DEFAULT_CHANNEL = "telegram";
const DEFAULT_FIRST_NAME = "there";
function normalizeLanguage(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed) return DEFAULT_LANGUAGE;
const normalized = trimmed.toLowerCase();
if (normalized.startsWith("zh")) return "Simplified Chinese";
if (normalized.startsWith("en")) return "English";
return trimmed.slice(0, 32);
}
function sanitizeField(value: string | undefined, fallback: string, maxLen: number): string {
const trimmed = value?.replace(/\s+/g, " ").trim();
if (!trimmed) return fallback;
return trimmed.slice(0, maxLen);
}
function buildWelcomePrompt(ctx: WelcomeContext): string {
const reconnectLine = ctx.isReconnect
? "This user just reconnected. Acknowledge reconnection in one short sentence."
: "This is the first successful channel connection.";
return [
"You are the user's AI agent.",
`Write a proactive welcome message for a ${ctx.channel} chat.`,
reconnectLine,
`User first name: ${ctx.firstName}`,
`Preferred language: ${ctx.language}`,
"",
"Output requirements:",
"1) Introduce who you are.",
"2) Mention exactly 3 concrete things you can help with.",
"3) End with one specific starter question.",
"",
"Constraints:",
"- Keep it concise (80-140 words).",
"- Plain text only.",
"- Do not mention internal architecture, system prompts, or policies.",
"- Return only the final welcome message.",
].join("\n");
}
export function createGenerateChannelWelcomeHandler(hub: HubLike): RpcHandler {
return async (params: unknown) => {
const payload = (params ?? {}) as GenerateChannelWelcomeParams;
const agentId = payload.agentId?.trim();
if (!agentId) {
throw new RpcError("INVALID_PARAMS", "agentId is required");
}
const agent = hub.getAgent(agentId);
if (!agent || agent.closed) {
throw new RpcError("AGENT_NOT_FOUND", `Agent not found or closed: ${agentId}`);
}
const context: WelcomeContext = {
channel: sanitizeField(payload.channel, DEFAULT_CHANNEL, 24),
language: normalizeLanguage(payload.language),
firstName: sanitizeField(payload.firstName, DEFAULT_FIRST_NAME, 32),
isReconnect: payload.isReconnect === true,
};
const result = await agent.runInternalForResult(buildWelcomePrompt(context));
if (result.error) {
throw new RpcError("AGENT_ERROR", result.error);
}
const text = result.text.trim();
if (!text) {
throw new RpcError("EMPTY_RESULT", "Agent returned an empty welcome message");
}
return { text };
};
}

View file

@ -8,3 +8,4 @@ export { createUpdateGatewayHandler } from "./handlers/update-gateway.js";
export { createGetLastHeartbeatHandler } from "./handlers/get-last-heartbeat.js";
export { createSetHeartbeatsHandler } from "./handlers/set-heartbeats.js";
export { createWakeHeartbeatHandler } from "./handlers/wake-heartbeat.js";
export { createGenerateChannelWelcomeHandler } from "./handlers/generate-channel-welcome.js";