Log command execution in config-driven replies
This commit is contained in:
149
src/index.ts
149
src/index.ts
@@ -65,6 +65,18 @@ type TwilioChannelsSender = {
|
|||||||
sender_id?: string;
|
sender_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type IncomingPhoneNumberUpdater = {
|
||||||
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IncomingPhoneNumbersClient = {
|
||||||
|
list: (params: {
|
||||||
|
phoneNumber: string;
|
||||||
|
limit?: number;
|
||||||
|
}) => Promise<IncomingNumber[]>;
|
||||||
|
get: (sid: string) => IncomingPhoneNumberUpdater;
|
||||||
|
} & ((sid: string) => IncomingPhoneNumberUpdater);
|
||||||
|
|
||||||
type TwilioSenderListClient = {
|
type TwilioSenderListClient = {
|
||||||
messaging: {
|
messaging: {
|
||||||
v2: {
|
v2: {
|
||||||
@@ -73,19 +85,16 @@ type TwilioSenderListClient = {
|
|||||||
channel: string;
|
channel: string;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}) => Promise<TwilioChannelsSender[]>;
|
}) => Promise<TwilioChannelsSender[]>;
|
||||||
(sid: string): {
|
(
|
||||||
|
sid: string,
|
||||||
|
): {
|
||||||
fetch: () => Promise<TwilioChannelsSender>;
|
fetch: () => Promise<TwilioChannelsSender>;
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
update: (params: Record<string, string>) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
incomingPhoneNumbers: {
|
incomingPhoneNumbers: IncomingPhoneNumbersClient;
|
||||||
list: (params: { phoneNumber: string; limit?: number }) => Promise<IncomingNumber[]>;
|
|
||||||
(sid: string): {
|
|
||||||
update: (params: Record<string, string>) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TwilioRequester = {
|
type TwilioRequester = {
|
||||||
@@ -349,13 +358,18 @@ async function getReplyFromConfig(
|
|||||||
// Choose reply from config: static text or external command stdout.
|
// Choose reply from config: static text or external command stdout.
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const reply = cfg.inbound?.reply;
|
const reply = cfg.inbound?.reply;
|
||||||
if (!reply) return undefined;
|
if (!reply) {
|
||||||
|
logVerbose("No inbound.reply configured; skipping auto-reply");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (reply.mode === "text" && reply.text) {
|
if (reply.mode === "text" && reply.text) {
|
||||||
|
logVerbose("Using text auto-reply from config");
|
||||||
return applyTemplate(reply.text, ctx);
|
return applyTemplate(reply.text, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reply.mode === "command" && reply.command?.length) {
|
if (reply.mode === "command" && reply.command?.length) {
|
||||||
|
logVerbose(`Running command auto-reply: ${reply.command.join(" ")}`);
|
||||||
const argv = reply.command.map((part) => applyTemplate(part, ctx));
|
const argv = reply.command.map((part) => applyTemplate(part, ctx));
|
||||||
const templatePrefix = reply.template
|
const templatePrefix = reply.template
|
||||||
? applyTemplate(reply.template, ctx)
|
? applyTemplate(reply.template, ctx)
|
||||||
@@ -364,10 +378,15 @@ async function getReplyFromConfig(
|
|||||||
? [argv[0], templatePrefix, ...argv.slice(1)]
|
? [argv[0], templatePrefix, ...argv.slice(1)]
|
||||||
: argv;
|
: argv;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(finalArgv[0], finalArgv.slice(1), {
|
if (globalVerbose) console.log(`RUN `);
|
||||||
|
const { stdout } = await execFileAsync(finalArgv[0], finalArgv.slice(1), {
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
});
|
});
|
||||||
return stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
|
logVerbose(
|
||||||
|
`Command auto-reply stdout (trimmed): ${trimmed || "<empty>"}`,
|
||||||
|
);
|
||||||
|
return trimmed || undefined;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Command auto-reply failed", err);
|
console.error("Command auto-reply failed", err);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -377,6 +396,54 @@ async function getReplyFromConfig(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function autoReplyIfConfigured(
|
||||||
|
client: ReturnType<typeof createClient>,
|
||||||
|
message: MessageInstance,
|
||||||
|
): Promise<void> {
|
||||||
|
// Fire a config-driven reply (text or command) for the inbound message, if configured.
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: message.body ?? undefined,
|
||||||
|
From: message.from ?? undefined,
|
||||||
|
To: message.to ?? undefined,
|
||||||
|
MessageSid: message.sid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyText = await getReplyFromConfig(ctx);
|
||||||
|
if (!replyText) return;
|
||||||
|
|
||||||
|
const replyFrom = message.to;
|
||||||
|
const replyTo = message.from;
|
||||||
|
if (!replyFrom || !replyTo) {
|
||||||
|
if (globalVerbose)
|
||||||
|
console.error(
|
||||||
|
"Skipping auto-reply: missing to/from on inbound message",
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logVerbose(
|
||||||
|
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyText.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.messages.create({
|
||||||
|
from: replyFrom,
|
||||||
|
to: replyTo,
|
||||||
|
body: replyText,
|
||||||
|
});
|
||||||
|
if (globalVerbose) {
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to auto-reply", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createClient(env: EnvConfig) {
|
function createClient(env: EnvConfig) {
|
||||||
// Twilio client using either auth token or API key/secret.
|
// Twilio client using either auth token or API key/secret.
|
||||||
if ("authToken" in env.auth) {
|
if ("authToken" in env.auth) {
|
||||||
@@ -757,15 +824,22 @@ async function findWhatsappSenderSid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findIncomingNumberSid(client: TwilioSenderListClient, url: string): Promise<string | null> {
|
async function findIncomingNumberSid(
|
||||||
|
client: TwilioSenderListClient,
|
||||||
|
): Promise<string | null> {
|
||||||
// Try to locate the underlying phone number and return its SID for webhook fallback.
|
// Try to locate the underlying phone number and return its SID for webhook fallback.
|
||||||
const env = readEnv();
|
const env = readEnv();
|
||||||
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
const phone = env.whatsappFrom.replace("whatsapp:", "");
|
||||||
try {
|
try {
|
||||||
const list = await client.incomingPhoneNumbers.list({ phoneNumber: phone, limit: 2 });
|
const list = await client.incomingPhoneNumbers.list({
|
||||||
|
phoneNumber: phone,
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
if (!list || list.length === 0) return null;
|
if (!list || list.length === 0) return null;
|
||||||
if (list.length > 1 && globalVerbose) {
|
if (list.length > 1 && globalVerbose) {
|
||||||
console.error(warn("Multiple incoming numbers matched; using the first."));
|
console.error(
|
||||||
|
warn("Multiple incoming numbers matched; using the first."),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return list[0]?.sid ?? null;
|
return list[0]?.sid ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -798,7 +872,10 @@ async function updateWebhook(
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (globalVerbose)
|
if (globalVerbose)
|
||||||
console.error("channelsSenders request update failed, will try client helpers", err);
|
console.error(
|
||||||
|
"channelsSenders request update failed, will try client helpers",
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) SDK helper fallback (if supported by this client)
|
// 2) SDK helper fallback (if supported by this client)
|
||||||
@@ -807,23 +884,27 @@ async function updateWebhook(
|
|||||||
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
await clientTyped.messaging.v2.channelsSenders(senderSid).update({
|
||||||
callbackUrl: url,
|
callbackUrl: url,
|
||||||
callbackMethod: method,
|
callbackMethod: method,
|
||||||
} as any);
|
});
|
||||||
console.log(success(`✅ Twilio sender webhook set to ${url}`));
|
console.log(success(`✅ Twilio sender webhook set to ${url}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (globalVerbose)
|
if (globalVerbose)
|
||||||
console.error("channelsSenders helper update failed, will try phone number fallback", err);
|
console.error(
|
||||||
|
"channelsSenders helper update failed, will try phone number fallback",
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Incoming phone number fallback (works for many WA senders)
|
// 3) Incoming phone number fallback (works for many WA senders)
|
||||||
try {
|
try {
|
||||||
const phoneSid = await findIncomingNumberSid(clientTyped, url);
|
const phoneSid = await findIncomingNumberSid(clientTyped);
|
||||||
if (phoneSid) {
|
if (phoneSid) {
|
||||||
await (clientTyped.incomingPhoneNumbers as any)(phoneSid).update({
|
const phoneNumberUpdater = clientTyped.incomingPhoneNumbers(phoneSid);
|
||||||
|
await phoneNumberUpdater.update({
|
||||||
smsUrl: url,
|
smsUrl: url,
|
||||||
smsMethod: method,
|
smsMethod: method,
|
||||||
} as any);
|
});
|
||||||
console.log(success(`✅ Twilio phone webhook set to ${url}`));
|
console.log(success(`✅ Twilio phone webhook set to ${url}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -884,21 +965,23 @@ async function monitor(intervalSeconds: number, lookbackMinutes: number) {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
messages
|
const inboundMessages = messages
|
||||||
.filter((m: MessageInstance) => m.direction === "inbound")
|
.filter((m: MessageInstance) => m.direction === "inbound")
|
||||||
.sort((a: MessageInstance, b: MessageInstance) => {
|
.sort((a: MessageInstance, b: MessageInstance) => {
|
||||||
const da = a.dateCreated?.getTime() ?? 0;
|
const da = a.dateCreated?.getTime() ?? 0;
|
||||||
const db = b.dateCreated?.getTime() ?? 0;
|
const db = b.dateCreated?.getTime() ?? 0;
|
||||||
return da - db;
|
return da - db;
|
||||||
})
|
|
||||||
.forEach((m: MessageInstance) => {
|
|
||||||
if (seen.has(m.sid)) return;
|
|
||||||
seen.add(m.sid);
|
|
||||||
const time = m.dateCreated?.toISOString() ?? "unknown time";
|
|
||||||
const fromNum = m.from ?? "unknown sender";
|
|
||||||
console.log(`\n[${time}] ${fromNum} -> ${m.to}: ${m.body ?? ""}`);
|
|
||||||
updateSince(m.dateCreated);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const m of inboundMessages) {
|
||||||
|
if (seen.has(m.sid)) continue;
|
||||||
|
seen.add(m.sid);
|
||||||
|
const time = m.dateCreated?.toISOString() ?? "unknown time";
|
||||||
|
const fromNum = m.from ?? "unknown sender";
|
||||||
|
console.log(`\n[${time}] ${fromNum} -> ${m.to}: ${m.body ?? ""}`);
|
||||||
|
updateSince(m.dateCreated);
|
||||||
|
await autoReplyIfConfigured(client, m);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error while polling messages", err);
|
console.error("Error while polling messages", err);
|
||||||
}
|
}
|
||||||
@@ -1130,10 +1213,10 @@ program
|
|||||||
);
|
);
|
||||||
await updateWebhook(client, senderSid, publicUrl, "POST");
|
await updateWebhook(client, senderSid, publicUrl, "POST");
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
"\nSetup complete. Leave this process running to keep the webhook online. Ctrl+C to stop.",
|
||||||
);
|
);
|
||||||
await waitForever();
|
await waitForever();
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parseAsync(process.argv);
|
program.parseAsync(process.argv);
|
||||||
|
|||||||
Reference in New Issue
Block a user