1 · 网关层

gateway/,99 个文件,其中 run.py 单文件 1.55 MB —— 是整个项目最大的模块。这一章讲一个常驻进程如何让 22 个聊天软件都能触达同一个智能体

1.1 它解决什么问题

先说清楚没有网关会怎样。假设你想让智能体支持 Slack 和 Telegram 两个平台:

朴素做法:写两个脚本 slack_bot.py ← 连 Slack,收到消息就调智能体 telegram_bot.py ← 连 Telegram,收到消息就调智能体 问题立刻出现: · 你在 Slack 聊到一半换 Telegram,上下文没了(两个进程,各存各的) · 定时任务该由谁触发?两个都触发就重复了 · 权限配置要写两份 · Slack 支持编辑消息(可以流式更新),Telegram 也支持但 API 完全不同 · 短信不支持编辑,流式输出根本没法做 · Discord 单条消息上限 2000 字,Telegram 是 4096 · 加第三个平台时,你发现前两个脚本里 70% 的代码是重复的

网关就是把这些共性抽出来的那一层。它是一个长期不关闭的后台进程,同时连着所有平台,负责:

  • 把各平台千奇百怪的消息格式归一化成统一的事件对象
  • 会话路由 —— 判断这条消息属于哪一场对话
  • 用户授权
  • 分发斜杠命令
  • 驱动定时任务的时钟
  • 管理智能体实例的缓存(同一场会话复用同一个实例)
  • 把回复按各平台的规则发出去

1.2 平台适配器抽象基类

gateway/platforms/base.py,333 KB。这是那份「插座标准」—— 规定任何一个平台想接进来必须提供什么。

核心思路:把差异抽象成「能力查询方法」

class BasePlatformAdapter(ABC):

    # —— 消息长度限制 ——
    def max_message_length_for_chat(self, chat_id: str) -> int
    def message_len_fn(self) -> Callable[[str], int]
    def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]

    # —— 流式输出能力 ——
    def supports_draft_streaming(self, ...) -> bool
    def prefers_fresh_final_streaming(self, ...) -> bool
    def streaming_overflow_limit(self) -> Optional[int]
    async def send_draft(self, ...)

    # —— 权限模型 ——
    def enforces_own_access_policy(self) -> bool
    def authorization_is_upstream(self) -> bool

    # —— 渲染 ——
    def render_message_event(self, event, sink) -> None
    def format_tool_event(self, event, *, mode: str = "all", ...) -> str
    def format_tool_preview(self, preview: "ToolPreview") -> str
    def set_status_text(self, chat_id: str, text: Optional[str]) -> None

hermes-agent/gateway/platforms/base.py

为什么是「能力查询」而不是「if 平台名」

对比两种写法:

# 写法 A:在主流程里判断平台
if platform == 'slack':
    max_len = 40000
elif platform == 'discord':
    max_len = 2000
elif platform == 'telegram':
    max_len = 4096
elif platform == 'sms':
    max_len = 160
# ... 22 个分支,而且这样的判断散落在几十处

# 写法 B:问适配器
max_len = adapter.max_message_length_for_chat(chat_id)

写法 A 的问题不是难看,是「新增平台要改几十个地方」。而且你不知道要改哪几个 —— 只能靠搜索和运气。

写法 B 下,新增一个平台 = 实现一组能力声明。主流程一行都不用动。而且抽象基类会强制你实现所有必需的方法,漏一个直接报错。

这就是这一层能撑住 22 个平台的根本原因。

被抽象出来的差异有多大

能力各平台的实际差异
能不能编辑已发消息
supports_draft_streaming
Slack / Telegram / Discord:能。所以可以流式更新同一条消息,用户看到文字逐渐生长。
短信:不能。只能发新消息 —— 流式输出根本没法做,只能等全部生成完再发一条。
偏好重发最终版吗
prefers_fresh_final_streaming
有些平台编辑消息会触发通知或者把消息顶到最新,体验很差。这类平台宁可「流式过程用草稿,最终结果发一条新的」。
单条消息长度上限
max_message_length_for_chat
Discord 2,000 / Telegram 4,096 / 短信 160 / Slack 约 40,000。
注意这个方法接收 chat_id 参数 —— 因为同一平台的不同频道可能有不同限制(比如企业版和免费版)。
长度怎么算
message_len_fn
更微妙:「长度」的定义各平台不同。有的按字符数,有的按 UTF-16 码元,有的把 emoji 算多个。所以返回的是一个计算函数而不是一个数字。
平台自己管权限吗
enforces_own_access_policy
企业微信 / 飞书:有完整的企业权限体系,能进到这个群的人就是被授权的。
IRC:完全没有权限概念。任何人都能发消息,必须由智能体自己做授权。
授权在上游吗
authorization_is_upstream
有些接入方式(比如通过企业网关代理)已经在上游做过身份认证,智能体这里不该再问一遍。

1.3 消息事件的归一化

各平台的消息格式完全不同。网关把它们统一成一个事件对象:

class MessageType(Enum): ...
class ProcessingOutcome(Enum): ...

class MessageEvent:
    ...
    def is_command(self) -> bool                 # 是不是斜杠命令
    def get_command(self) -> Optional[str]       # 命令名
    def get_command_args(self) -> str            # 命令参数

class CachedMedia:
    def context_note(self) -> str                # 附件的上下文说明

class TextDebounceState: ...                     # ★ 文本去抖动
class SendResult: ...

class EphemeralReply(str):                       # ★ 带过期时间的临时回复
    def __new__(cls, text: str, ttl_seconds: Optional[int] = None): ...
    def text(self) -> str: ...

两个值得单独说的类

TextDebounceState(文本去抖动状态)解决的是这个场景:

用户在 Telegram 上连发三条: 09:15:01 "帮我看看" 09:15:03 "那个报错" 09:15:05 "在 server.py 里" 如果每条都立刻触发一次智能体: → 触发三次,前两次都是无效的(信息不完整) → 浪费三次模型调用,而且模型会被前两次的半截问题带偏 去抖动:收到消息后先等一小会儿,如果这期间又来了消息, 就合并起来一起处理。用户"打字打了一半发出去"的 习惯被正确处理了。

EphemeralReply(临时回复)是一个继承自 str 的类,带一个过期时间。用于那些「说完就该消失」的消息 —— 比如「正在思考…」这类状态提示。在支持的平台上,这类消息会在一段时间后自动删除,不污染聊天记录。

让它继承 str 是一个很实用的设计:所有原本处理字符串的代码不用改,照常工作;只有关心过期时间的代码才去检查它是不是 EphemeralReply

1.4 网关主循环里的那些防御机制

run.py 里的函数名本身就是一份「长驻进程会遇到什么问题」的清单:

① 卫生冷却:防止压缩失败反复重试

def _hygiene_cooldown_for_failure(...)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None
def hygiene_compaction_recovered(...)
def _record_hygiene_cooldown(...)

「卫生」(hygiene)在这里指的是后台自动做的上下文整理。如果某个会话的压缩连续失败,就给它一个冷却期,别再反复尝试 —— 否则会持续消耗资源而且持续失败。

_reset_hygiene_failure_streak(重置失败连击)这个命名说明它跟踪的是连续失败次数,成功一次就清零。这和第 3 章会讲的幂等锁是同一个思路。

② 瞬时网络错误识别

def _is_transient_network_error(exc: BaseException) -> bool

长驻进程必须区分「网络抖了一下」和「真的出问题了」。前者应该静默重试,后者应该告警。如果不区分,用户会被无意义的网络波动告警淹没,最终屏蔽所有告警。

③ 用户可见文本的密钥脱敏

def _redact_gateway_user_facing_secrets(text: str) -> str
def _redact_approval_command(cmd: "str | None") -> str

网关会把一些内部信息发到聊天窗口(错误提示、审批请求)。这些文本里可能夹带 API 密钥、令牌、密码。而聊天窗口是多人可见的、会被搜索的、会被归档的。所以发出去之前必须脱敏。

注意有两个脱敏函数:一个给通用文本,一个专门给「待审批的命令」。因为命令里的密钥形态不一样(可能在环境变量赋值里、在参数里、在管道里)。

④ 供应商错误的用户友好化

def _gateway_provider_error_reply(text: str) -> str
def _looks_like_gateway_provider_error(text: str) -> bool
def _sanitize_gateway_final_response(platform: Any, text: str) -> str

模型服务返回的错误信息通常是给开发者看的(含堆栈、内部错误码、请求 ID)。直接发到聊天窗口对用户毫无意义。所以要识别出来并翻译成人话。

⑤ 重启守卫

gateway/restart.py
gateway/restart_loop_guard.py     ← ★ 重启风暴防护

重启风暴是长驻进程的经典故障:进程崩溃 → 自动重启 → 启动时又崩 → 又重启 → 无限循环。每秒重启几十次,日志被刷爆,CPU 打满。

守卫的做法通常是:记录最近的重启次数和间隔,如果在短时间内重启太多次,就停止自动重启并保持崩溃状态 —— 让人来看一眼。

⑥ 内存监控与优雅排空

gateway/memory_monitor.py           内存占用监控
gateway/agent_cache_pressure.py     智能体缓存压力
gateway/drain_control.py            ★ 优雅排空
gateway/disk_status.py              磁盘状态

智能体缓存压力:网关会缓存智能体实例(同一场会话复用同一个)。但每个实例都持有完整的消息历史 —— 几十场活跃会话就能吃掉几 GB 内存。所以需要监控压力并在必要时淘汰不活跃的实例。

优雅排空:要关闭网关时,不能直接杀掉 —— 手头正在处理的消息会丢。正确做法是「停止接受新消息,把手头的处理完,然后退出」。

⑦ 投递账本

gateway/delivery.py
gateway/delivery_ledger.py     ← ★ 投递账本
gateway/rich_sent_store.py
gateway/message_timestamps.py
gateway/dead_targets.py        ← 死目标(比如被删掉的频道)

投递账本记录「哪条回复已经发到哪里了」。它防的是重复投递:网络超时时你不知道消息发出去没有,重试可能导致用户收到两遍。有账本就能判断。

死目标处理的是:智能体要回复的那个频道被删了、机器人被踢出群了、用户拉黑了。这些投递会永久失败,必须识别出来并停止重试,否则重试队列会无限增长。

1.5 状态消息与进度反馈

def _status_template_to_regex(template: str) -> str
def _gateway_compression_progress_notices_enabled() -> bool
def _prepare_gateway_status_message(platform, event_type: str, message: str) -> Optional[str]
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata)
def render_notice_line(notice) -> str

智能体在长任务中需要给用户反馈「我还在干活」。但在聊天软件里做这件事很微妙:

  • 发太多状态消息 → 刷屏
  • 不发 → 用户以为死了
  • 用编辑同一条消息的方式更新 → 只在支持编辑的平台可行

_status_template_to_regex 这个函数值得注意:它把状态消息的模板转成正则表达式。用途应该是「识别聊天记录里哪些消息是自己之前发的状态消息」,从而可以更新或删除它们 —— 因为平台 API 返回的消息 ID 可能已经丢失,只能靠内容匹配来找。

1.6 中断与恢复

def _is_fresh_gateway_interruption(...)
def build_resume_recovery_note(...)
def _prepare_resume_pending_message(...)
def _build_replay_entry(...)
def _startup_restore_drain_timeout_secs() -> float
def _auto_continue_freshness_window() -> float

这一组函数处理的是:网关重启后,那些「进行到一半」的会话怎么办。

场景:智能体正在处理一个长任务,网关进程被重启了(部署 / 崩溃 / 内存不足) 重启后需要判断: ① 这次中断是"新鲜"的吗?(_is_fresh_gateway_interruption) 刚断的可以直接续上;断了两小时的,用户可能已经不关心了 ② 要不要自动继续?(_auto_continue_freshness_window) 有一个"新鲜度窗口" —— 超过这个时间就不自动继续,等用户说话 ③ 给用户什么解释?(build_resume_recovery_note) 不能默默地接着干,用户会困惑。要说明"我刚才被中断了,现在继续" ④ 启动时排空积压消息要等多久?(_startup_restore_drain_timeout_secs) 重启期间用户可能发了几条消息,要先处理完再进入正常状态

这一组函数是「长驻」和「命令行工具」的本质差别所在。

命令行工具崩了就崩了,用户重新跑一次。长驻进程崩了之后必须自己判断「刚才干到哪了、该不该继续、要不要告诉用户」 —— 而且判断依据只有磁盘上的状态。

这部分逻辑在架构图上完全看不见,但它占了网关模块相当大的比重。

1.7 网关内置钩子

gateway/hooks.py
gateway/builtin_hooks/

网关层有自己的钩子系统,让插件可以在消息进出的关键节点插入逻辑。和第 9 章讲的插件系统配合,构成了「不改核心代码就能扩展网关行为」的能力。

1.8 这一层的代价

1.55 MB 的单文件是这一层最直观的代价。它不是设计缺陷,是「22 个平台 × 每个平台的边角情况」组合爆炸的必然结果。

从函数名可以看出,这个文件里塞了:冷却策略、错误分类、脱敏、状态渲染、时间戳处理、审批转发、进度线程解析、平台显示配置、Telegram 特有的提及格式转换(_telegramize_command_mentions)……

每一个都很小,但加起来就是 1.55 MB。而且它们大多无法被抽象掉 —— 因为它们本质上就是在处理外部世界的不规则性。

1 · The Gateway Layer

gateway/: 99 files, including run.py, a single 1.55 MB file — the largest module in the whole project. This chapter is about how one always-on process lets 22 chat apps reach the same agent.

1.1 The Problem It Solves

Start with what happens without a gateway. Say you want the agent to support two platforms, Slack and Telegram:

The naive approach: write two scripts slack_bot.py ← connects to Slack, calls the agent on each message telegram_bot.py ← connects to Telegram, calls the agent on each message The problems show up immediately: · You switch from Slack to Telegram mid-conversation and the context is gone (two processes, each with its own state) · Who fires the scheduled tasks? If both do, they run twice · Permission config has to be written twice · Slack supports editing messages (so you can stream updates); Telegram does too, but with a completely different API · SMS can't edit at all, so streaming output is simply impossible · Discord caps a single message at 2000 characters, Telegram at 4096 · When you add a third platform, you find that 70% of the code in the first two scripts is duplicated

The gateway is the layer that pulls all of that shared logic out. It is a background process that never shuts down, connected to every platform at once, and it is responsible for:

  • Normalizing the wildly different message formats of each platform into a uniform event object
  • Session routing — deciding which conversation a message belongs to
  • User authorization
  • Dispatching slash commands
  • Driving the clock for scheduled tasks
  • Managing the cache of agent instances (the same session reuses the same instance)
  • Sending replies out according to each platform's rules

1.2 The Platform Adapter Abstract Base Class

gateway/platforms/base.py, 333 KB. This is the “wall-socket standard” — it specifies what any platform must provide in order to plug in.

The core idea: turn the differences into “capability query methods”

class BasePlatformAdapter(ABC):

    # —— message length limits ——
    def max_message_length_for_chat(self, chat_id: str) -> int
    def message_len_fn(self) -> Callable[[str], int]
    def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]

    # —— streaming capabilities ——
    def supports_draft_streaming(self, ...) -> bool
    def prefers_fresh_final_streaming(self, ...) -> bool
    def streaming_overflow_limit(self) -> Optional[int]
    async def send_draft(self, ...)

    # —— permission model ——
    def enforces_own_access_policy(self) -> bool
    def authorization_is_upstream(self) -> bool

    # —— rendering ——
    def render_message_event(self, event, sink) -> None
    def format_tool_event(self, event, *, mode: str = "all", ...) -> str
    def format_tool_preview(self, preview: "ToolPreview") -> str
    def set_status_text(self, chat_id: str, text: Optional[str]) -> None

hermes-agent/gateway/platforms/base.py

Why “capability queries” instead of “if platform name”

Compare the two styles:

# Style A: branch on the platform in the main flow
if platform == 'slack':
    max_len = 40000
elif platform == 'discord':
    max_len = 2000
elif platform == 'telegram':
    max_len = 4096
elif platform == 'sms':
    max_len = 160
# ... 22 branches, and checks like this scattered across dozens of places

# Style B: ask the adapter
max_len = adapter.max_message_length_for_chat(chat_id)

The problem with Style A isn't that it's ugly. It's that “adding a platform means editing dozens of places.” And you don't know which places — you find them by grep and by luck.

Under Style B, adding a platform = implementing one set of capability declarations. Not a single line of the main flow changes. And the abstract base class forces you to implement every required method; miss one and it errors out immediately.

This is the fundamental reason this layer can carry 22 platforms.

How big are the differences being abstracted away?

CapabilityHow the platforms actually differ
Can it edit a sent message?
supports_draft_streaming
Slack / Telegram / Discord: yes. So the same message can be updated in a stream, and the user watches the text grow.
SMS: no. It can only send new messages — streaming is simply not possible; you wait for the full response and send it in one go.
Does it prefer a fresh final message?
prefers_fresh_final_streaming
On some platforms, editing a message fires a notification or bumps the message to the top, which is a bad experience. Those platforms would rather “use a draft while streaming, then send the final result as a new message.”
Per-message length cap
max_message_length_for_chat
Discord 2,000 / Telegram 4,096 / SMS 160 / Slack roughly 40,000.
Note that this method takes a chat_id argument — because different channels on the same platform can have different limits (enterprise vs. free tier, for instance).
How length is counted
message_len_fn
Subtler: the very definition of “length” differs by platform. Some count characters, some count UTF-16 code units, some count an emoji as several. So it returns a counting function, not a number.
Does the platform manage permissions itself?
enforces_own_access_policy
WeCom / Feishu: they have a full enterprise permission system; anyone who can get into the group is, by definition, authorized.
IRC: no concept of permissions at all. Anyone can send a message, so the agent must do authorization itself.
Is authorization upstream?
authorization_is_upstream
Some integration paths (through an enterprise gateway proxy, say) have already authenticated the identity upstream, and the agent shouldn't ask again.

1.3 Normalizing Message Events

Every platform has a completely different message format. The gateway unifies them into one event object:

class MessageType(Enum): ...
class ProcessingOutcome(Enum): ...

class MessageEvent:
    ...
    def is_command(self) -> bool                 # is this a slash command?
    def get_command(self) -> Optional[str]       # command name
    def get_command_args(self) -> str            # command arguments

class CachedMedia:
    def context_note(self) -> str                # context note for an attachment

class TextDebounceState: ...                     # ★ text debouncing
class SendResult: ...

class EphemeralReply(str):                       # ★ a temporary reply with an expiry
    def __new__(cls, text: str, ttl_seconds: Optional[int] = None): ...
    def text(self) -> str: ...

Two classes worth calling out

TextDebounceState handles this scenario:

A user fires off three messages in a row on Telegram: 09:15:01 "take a look at" 09:15:03 "that error" 09:15:05 "in server.py" If each one triggers the agent immediately: → three runs, the first two useless (incomplete information) → three wasted model calls, and the model gets led astray by the two half-questions Debouncing: after a message arrives, wait a moment; if more messages come in during that window, merge them and handle them together. The habit of "hitting send halfway through typing" is handled correctly.

EphemeralReply is a class that inherits from str and carries an expiry time. It is for messages that “should disappear once said” — status hints like “Thinking…”. On platforms that support it, these messages delete themselves after a while and don't clutter the chat history.

Inheriting from str is a very practical design: every piece of code that already handles strings keeps working unchanged; only the code that cares about expiry checks whether it is an EphemeralReply.

1.4 The Defensive Mechanisms in the Gateway Main Loop

The function names in run.py are, on their own, a checklist of “what goes wrong for a long-running process”:

① Hygiene cooldown: stop retrying failed compaction

def _hygiene_cooldown_for_failure(...)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None
def hygiene_compaction_recovered(...)
def _record_hygiene_cooldown(...)

“Hygiene” here means the automatic context tidying done in the background. If compaction keeps failing for a session, give it a cooldown period rather than trying again and again — otherwise it burns resources continuously and fails continuously.

The name _reset_hygiene_failure_streak tells you it tracks a count of consecutive failures, reset to zero on the first success. This is the same idea as the idempotency lock in Chapter 3.

② Recognizing transient network errors

def _is_transient_network_error(exc: BaseException) -> bool

A long-running process has to tell “the network hiccuped” apart from “something is actually broken.” The former should retry silently; the latter should alert. If you don't distinguish them, users drown in meaningless alerts about network blips and eventually mute all alerts.

③ Redacting secrets from user-visible text

def _redact_gateway_user_facing_secrets(text: str) -> str
def _redact_approval_command(cmd: "str | None") -> str

The gateway sends some internal information into the chat window (error messages, approval requests). That text may carry API keys, tokens, and passwords. And a chat window is visible to many people, searchable, and archived. So it must be redacted before it goes out.

Note that there are two redaction functions: one for general text, one specifically for “the command awaiting approval.” Secrets in a command take different shapes (in an environment variable assignment, in an argument, in a pipeline).

④ Making provider errors user-friendly

def _gateway_provider_error_reply(text: str) -> str
def _looks_like_gateway_provider_error(text: str) -> bool
def _sanitize_gateway_final_response(platform: Any, text: str) -> str

Error messages from model services are usually written for developers (stack traces, internal error codes, request IDs). Sent straight into a chat window, they mean nothing to the user. So they have to be recognized and translated into plain language.

⑤ Restart guard

gateway/restart.py
gateway/restart_loop_guard.py     ← ★ restart storm protection

A restart storm is the classic failure of a long-running process: the process crashes → auto-restarts → crashes again on startup → restarts again → forever. Dozens of restarts per second, logs flooded, CPU pinned.

The usual guard: record recent restart counts and intervals, and if there are too many restarts in a short window, stop auto-restarting and stay down — so a human takes a look.

⑥ Memory monitoring and graceful drain

gateway/memory_monitor.py           memory usage monitoring
gateway/agent_cache_pressure.py     agent cache pressure
gateway/drain_control.py            ★ graceful drain
gateway/disk_status.py              disk status

Agent cache pressure: the gateway caches agent instances (the same session reuses the same one). But every instance holds the full message history — a few dozen active sessions can eat several GB of memory. So the pressure has to be monitored, and inactive instances evicted when necessary.

Graceful drain: when the gateway needs to shut down, you can't just kill it — messages currently being processed would be lost. The right way is “stop accepting new messages, finish what's in hand, then exit.”

⑦ Delivery ledger

gateway/delivery.py
gateway/delivery_ledger.py     ← ★ delivery ledger
gateway/rich_sent_store.py
gateway/message_timestamps.py
gateway/dead_targets.py        ← dead targets (a deleted channel, say)

The delivery ledger records “which reply has already been sent where.” It guards against duplicate delivery: on a network timeout you don't know whether the message went out, and a retry could make the user see it twice. With a ledger, you can tell.

Dead targets covers the cases where the channel the agent wants to reply to has been deleted, the bot has been kicked from the group, or the user has blocked it. These deliveries will fail forever, and they must be recognized and retries stopped, or the retry queue grows without bound.

1.5 Status Messages and Progress Feedback

def _status_template_to_regex(template: str) -> str
def _gateway_compression_progress_notices_enabled() -> bool
def _prepare_gateway_status_message(platform, event_type: str, message: str) -> Optional[str]
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata)
def render_notice_line(notice) -> str

During a long task the agent needs to tell the user “I'm still working.” But doing that inside a chat app is delicate:

  • Too many status messages → spam
  • None → the user thinks it died
  • Updating by editing the same message → only works on platforms that support editing

The function _status_template_to_regex deserves a note: it converts a status-message template into a regular expression. The likely purpose is “recognize which messages in the chat history are status messages I sent earlier,” so they can be updated or deleted — because the message IDs returned by the platform API may already be lost, and matching on content is the only way to find them.

1.6 Interruption and Recovery

def _is_fresh_gateway_interruption(...)
def build_resume_recovery_note(...)
def _prepare_resume_pending_message(...)
def _build_replay_entry(...)
def _startup_restore_drain_timeout_secs() -> float
def _auto_continue_freshness_window() -> float

This group of functions handles: after the gateway restarts, what happens to the sessions that were “halfway through”?

Scenario: the agent is in the middle of a long task and the gateway process gets restarted (deploy / crash / out of memory) After the restart it has to decide: ① Is this interruption "fresh"? (_is_fresh_gateway_interruption) One that just happened can be resumed directly; one from two hours ago, the user may no longer care about ② Should it continue automatically? (_auto_continue_freshness_window) There is a "freshness window" — past it, don't auto-continue; wait for the user to speak ③ What does it tell the user? (build_resume_recovery_note) It can't just silently carry on; the user would be confused. It should say "I was interrupted; continuing now" ④ How long to wait while draining the backlog at startup? (_startup_restore_drain_timeout_secs) The user may have sent several messages during the restart; process them before returning to normal

This group of functions is where the essential difference between “always-on” and “command-line tool” lives.

When a command-line tool crashes, it crashes; the user runs it again. When a long-running process crashes, it has to work out on its own “where was I, should I continue, should I tell the user” — and the only evidence it has is the state on disk.

None of this logic shows up on an architecture diagram, but it accounts for a substantial share of the gateway module.

1.7 Built-in Gateway Hooks

gateway/hooks.py
gateway/builtin_hooks/

The gateway layer has its own hook system, letting plugins insert logic at the key points where messages enter and leave. Together with the plugin system in Chapter 9, this gives you the ability to “extend gateway behavior without changing core code.”

1.8 What This Layer Costs

A single 1.55 MB file is the most visible cost of this layer. It isn't a design flaw; it is the inevitable result of the combinatorial explosion of “22 platforms × every platform's edge cases.”

You can tell from the function names what's packed into this file: cooldown policy, error classification, redaction, status rendering, timestamp handling, approval forwarding, progress-thread parsing, per-platform display config, Telegram-specific mention format conversion (_telegramize_command_mentions)…

Each one is small, but together they add up to 1.55 MB. And most of them can't be abstracted away — because what they are doing, fundamentally, is handling the irregularity of the outside world.