#OpenClaw question about custom channel lifecycle.

1 messages · Page 1 of 1 (latest)

pastel plover
#

Environment:

  • OpenClaw 2026.4.14
  • custom channel plugin: whatsapp-cloud
  • plugin starts an HTTP webhook server on port 3100 inside startAccount()

Observed behavior:

  • startAccount() starts the webhook listener, logs “Channel started”, then returns
  • OpenClaw runtime then treats the channel as exited and schedules auto-restart
  • next restart hits:
    Webhook server error: listen EADDRINUSE: address already in use :::3100
  • this repeats with auto-restart attempts

Relevant runtime behavior:

  • in server.impl, startAccount() is wrapped as a tracked promise
  • when that promise resolves/finally, runtime marks channel stopped and schedules restart

Question:
For custom channels, is startAccount() expected to remain pending/alive until abortSignal fires?
If yes, is the correct pattern:

  • start webhook server
  • await a promise tied to abortSignal
  • on abort, close the server and resolve

Current hypothesis:
our plugin is wrong because it starts the server and returns immediately, so OpenClaw restarts it forever.

Can you confirm the intended lifecycle contract for custom channel startAccount()?

vital spearBOT
rough copperBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> âś… Mark Solution

vital spearBOT
slate ledgeBOT
#

I’m checking the runtime code path directly, because this smells like a lifecycle contract issue rather than WhatsApp-specific behavior.

#

Yes, your hypothesis is right.

startAccount() is effectively the account’s live runtime task. It is expected to stay pending while that channel account is “running”, and only resolve when shutdown is happening, usually because abortSignal fired or the underlying monitor/server stopped.

So in your case:

  • start webhook server
  • keep startAccount() pending
  • on abortSignal, close/unregister the server
  • only then resolve

If startAccount() returns immediately after listen(), OpenClaw treats that as “channel exited”, marks it stopped, and schedules auto-restart. That matches the EADDRINUSE loop you’re seeing.

For an HTTP webhook server, the best pattern is:

#
import { keepHttpServerTaskAlive } from "openclaw/plugin-sdk/channel-lifecycle";

startAccount: async (ctx) => {
  const server = app.listen(3100);

  await keepHttpServerTaskAlive({
    server,
    abortSignal: ctx.abortSignal,
    onAbort: async () => {
      await new Promise<void>((resolve) => server.close(() => resolve()));
    },
  });
}

If you are not holding a real http.Server, then waitUntilAbort(ctx.abortSignal, cleanup) is the simpler pattern.

#

I checked the current source and tests, and they’re pretty explicit about this lifecycle:

  • runtime tracks startAccount() and restarts when it resolves: src/gateway/server-channels.ts
  • SDK helpers exist specifically for this: openclaw/plugin-sdk/channel-lifecycle
  • bundled channels test “keeps startAccount pending until abort”

Docs: https://docs.openclaw.ai/plugins/sdk-channel-plugins

Glad you caught it, this is exactly the kind of bug that causes nasty restart loops. If this fixed it, please right-click the solution message → Apps → ✅ Mark Solution, so Answer Overflow can index it.

pastel plover
#

Telegram iOS deep-link behavior question.

We open a bot via either:

  • https://t.me/<bot>?start=link_<token>
  • tg://resolve?domain=<bot>&start=link_<token>

Observed problem:

  • Telegram opens
  • user lands in the bot chat
  • but the bot does not receive the tokenized /start payload reliably
  • plain messages then arrive normally afterward

Context:

  • this is for linking an existing app account to a Telegram bot chat
  • repeated relinks / reopened chats are common
  • iOS specifically seems flaky here

Questions:

  1. On Telegram iOS, is the start payload only guaranteed on first open / first chat creation?
  2. Is there a known difference in reliability between https://t.me/... and tg://resolve?... for bot start payload delivery?
  3. If the bot chat already exists, can Telegram iOS suppress or ignore the start parameter?
  4. Is there any reliable pattern for account-link handoff besides trusting the hidden start payload?
slate ledgeBOT
#

Let me check the Telegram side before I guess, because iOS deep-link behavior is annoyingly inconsistent.

#

Short version: I would not trust Telegram iOS deep-link /start payloads as a reliable account-link transport, especially for reopened/existing chats.

What I found:

  1. Is it only guaranteed on first open / first chat creation?
    Not as an official contract, no. Telegram’s deep-link model is supposed to pass a start param to the bot, but on iOS it is not reliable on repeated opens. There are iOS bug reports where the first deep link works and later ones do nothing.

  2. https://t.me/... vs tg://resolve?...
    Telegram’s docs treat these as two flavors of the same deep link, not different semantics. So I would not assume one has a stronger delivery guarantee for start.
    Practical recommendation: prefer https://t.me/... for fallback/install behavior, but not because it makes start more reliable.

  3. Can existing chats suppress/ignore the start param?
    Yes, in practice on iOS. That lines up with your symptoms and with public iOS bug reports. Existing/opened chats seem especially prone to this.

  4. Reliable handoff pattern?
    Best pattern is:

    • use the deep link only to open the bot chat
#
  • do not make the hidden start payload your only source of truth
    • have a fallback explicit action:
      • /link <short_code>
      • paste/send a one-time code
      • or complete linking via a bot Web App / explicit button flow

My recommendation for account linking:

  • Generate a short-lived one-time code/token
  • Open Telegram with the deep link as a convenience
  • If the bot doesn’t receive the tokenized /start, let the user:
    • tap Start, then
    • send/paste the code, or
    • press a visible “Link account” button that continues the flow

So yes, your interpretation is basically right: on Telegram iOS, repeated relinks into an existing chat can absolutely drop the hidden start payload, so you need a fallback path.

pastel plover
#

Yes please, cause I tried for 4 hours and nothing worked

slate ledgeBOT
#

Yeah, that’s frustrating as hell. I wouldn’t spend another 4 hours trying to make iOS deep-link /start fully reliable. I’d ship around it.

The flow I’d recommend:

1) Use a dual-path link flow

When the user taps Connect Telegram in your app/backend, create one pending link session with:

  • deepLinkToken = long opaque token, for /start
  • userCode = short human-friendly code, like 482913 or AB7KQ2
  • expiresAt = 5 to 10 min
  • status = pending

Both values map to the same pending link session.

2) Open Telegram with the deep link, but show the fallback code too

#

Open:

https://t.me/<bot>?start=link_<deepLinkToken>

But in the app/UI also show:

If Telegram doesn’t link automatically, send this code to the bot: 482913

That way:

  • happy path = /start link_<token> arrives, instant link
  • iOS broken path = user sends code manually, still works

3) Make the bot accept both

#

Support both:

  • /start link_<token>
  • /link <userCode>

And honestly I’d also accept just the raw code in DM:

  • 482913

because users will do that anyway.

4) Treat any plain DM from an unlinked user as a recovery point

Since you said plain messages arrive normally afterward, use that.

If an unlinked user messages the bot without a valid token/code, reply with:

#

I didn’t receive the iOS deep-link payload.
Please send the 6-digit code from the app, like /link 482913.

That turns Telegram’s flakiness into a recoverable UX instead of a dead end.


Minimal backend shape

type PendingLink = {
  appAccountId: string;
  deepLinkToken: string;
  userCode: string;
  expiresAt: number;
  usedAt?: number;
#
};

Redemption logic:

async function redeemPendingLink({
  telegramUserId,
  telegramChatId,
  tokenOrCode,
}: {
  telegramUserId: string;
  telegramChatId: string;
  tokenOrCode: string;
}) {
#
  const pending = await findPendingLinkByTokenOrCode(tokenOrCode);
  if (!pending) throw new Error("invalid_or_expired");

  if (pending.usedAt) throw new Error("already_used");
  if (Date.now() > pending.expiresAt) throw new Error("expired");

  await linkTelegramToAppAccount({
    appAccountId: pending.appAccountId,
    telegramUserId,
    telegramChatId,
  });

  await markPendingLinkUsed(pending);
}
#

Bot handler sketch:

if (/^\/start\s+link_(.+)$/.test(text)) {
  await redeemPendingLink({ telegramUserId, telegramChatId, tokenOrCode: match[1] });
  await send("Linked successfully âś…");
  return;
}

if (/^\/link\s+([A-Z0-9-]+)$/i.test(text)) {
  await redeemPendingLink({ telegramUserId, telegramChatId, tokenOrCode: match[1] });
  await send("Linked successfully âś…");
  return;
}

if (/^[A-Z0-9-]{6,10}$/i.test(text)) {
#
  await redeemPendingLink({ telegramUserId, telegramChatId, tokenOrCode: text.trim() });
  await send("Linked successfully âś…");
  return;
}

if (!isLinked(telegramUserId, telegramChatId)) {
  await send("I didn’t get the Telegram deep-link payload. Please send the code from the app, for example: /link 482913");
}

Why this works better

Because it does not depend on hidden client behavior being correct.

#

You’re using:

  • deep link = convenience
  • plain message/code = reliable transport

That is the part Telegram iOS can’t break.


Small implementation tips

  • Make the code short, not the real token.
  • One-time use only.
  • Expire fast, 5 to 10 minutes.
  • Rate limit code attempts.
  • Bind by from.id + chat.id, not username.
  • If relinking is common, make redemption idempotent for the same app account + Telegram user.