跪拜 Guibai
← Back to the summary

A QQ AI Bot That Remembers, Listens, and Edits Feishu Docs: Full Deployment Walkthrough

Building a QQ "Da Fei Yu" AI Bot from Scratch That Can Chat, Search, and Operate Feishu Documents

One Ubuntu server, two Docker containers, five custom plugins, stringing DeepSeek, local voice, Feishu CLI, and an Agent runtime into a complete pipeline.
This is a full retrospective, including copy-paste commands, pitfalls encountered, and the final architecture.


I. Preface: What exactly are we doing

The goal is simple: raise an AI pet in QQ.

But "just chatting" is only the starting point; the hard part is making it:

The final result is a pipeline:

flowchart LR
    QQ[QQ user] -->|message| NC[NapCat<br/>OneBot v11]
    NC -->|reverse WS| AB[AstrBot<br/>conversation hub]
    AB --> LLM[DeepSeek<br/>deepseek-flash]
    AB --> WS[DeepSeek search<br/>tools/search]
    AB --> STT[Local Whisper<br/>speech-to-text]
    AB --> TTS[Edge TTS<br/>Xiaoyi girl voice]
    AB --> PLUG[Custom plugins<br/>wake gate/voice gate/DSH bridge]
    PLUG -->|dsh headless| DSH[DSH Agent]
    DSH -->|lark-cli| FS[Feishu cloud docs]
    DSH -->|bash/file| SYS[server container environment]

One-sentence tech stack summary:

Layer Choice
Message channel NapCat (OneBot v11)
Conversation hub AstrBot 4.29.3
Model DeepSeek deepseek-flash
Speech recognition Local Whisper small (CPU)
Speech synthesis Microsoft Edge TTS zh-CN-XiaoyiNeural
Agent DeepSeek Harness dsh --profile headless
Documents Feishu official CLI @larksuite/cli

II. Server and Base Environment

2.1 Server

Ubuntu 24.04.3 LTS / 4 vCPU / 15 GB RAM / 885 GB disk
Docker 29.4.1 + Compose v5.5

The machine already runs 10 production containers (kodbox, official site, backend demo, etc.).
Principle: only add containers, only bind internal IPs, never restart the Docker daemon, and don't touch existing services.

2.2 First Hurdle Pulling Images: Docker Hub Unreachable

Direct connection to registry-1.docker.io from a domestic server basically times out. Two solutions:

Option A: Edit daemon.json to add a mirror accelerator (good for new machines, but requires restarting Docker)

{
  "registry-mirrors": ["https://mirror.ccs.tencentyun.com"]
}

Option B: Don't touch the daemon; pull with a mirror prefix and retag (good for production machines, strongly recommended)

docker pull docker.m.daocloud.io/soulter/astrbot:latest
docker tag docker.m.daocloud.io/soulter/astrbot:latest soulter/astrbot:latest

I used Option B on the production machine — restarting Docker would restart all existing containers, and the risk is unacceptable.


III. Deploy AstrBot + NapCat

3.1 Create a Dedicated Network

docker network create astrbot-napcat

3.2 Start AstrBot

docker run -d --name astrbot --network astrbot-napcat --restart always \
  -p 192.168.0.70:6185:6185 \
  -p 192.168.0.70:6199:6199 \
  -v /home/docmap/astrbot/data:/AstrBot/data \
  soulter/astrbot:latest

3.3 Start NapCat

docker run -d --name napcat --network astrbot-napcat --restart always \
  -p 192.168.0.70:6099:6099 \
  -e NAPCAT_UID=1000 -e NAPCAT_GID=1000 \
  -v /home/docmap/napcat/config:/app/napcat/config \
  -v /home/docmap/napcat/qq:/app/.config/QQ \
  mlikiowa/napcat-docker:latest

3.4 QR Code Login

After NapCat starts, it prints a QR code and WebUI token in the logs:

WebUi User Panel Url: http://127.0.0.1:6099/webui?token=xxxxxxxx
QR decode URL: https://txz.qq.com/p?k=...

Scan with your phone's QQ and confirm on the phone.

Note: NapCat requires a fresh QR scan on every restart; the login state is not persistent.
So for any AstrBot changes, restart only AstrBot, not NapCat.

3.5 Wire Up the Reverse WebSocket

Add a WS client in NapCat's config:

{
  "network": {
    "websocketClients": [
      {
        "name": "astrbot",
        "enable": true,
        "url": "ws://astrbot:6199/ws",
        "messagePostFormat": "array",
        "token": "",
        "heartInterval": 30000,
        "reconnectInterval": 5000
      }
    ]
  }
}

On the AstrBot side, add the OneBot platform in cmd_config.json:

{
  "platform": [
    {
      "id": "default",
      "type": "aiocqhttp",
      "enable": true,
      "ws_reverse_host": "0.0.0.0",
      "ws_reverse_port": 6199,
      "ws_reverse_token": ""
    }
  ]
}

Once connected, the logs show:

aiocqhttp(OneBot v11) adapter connected.

IV. Integrate DeepSeek: Model and Search

4.1 Model Provider

{
  "id": "deepseek-flash",
  "provider": "deepseek",
  "type": "openai_chat_completion",
  "provider_type": "chat_completion",
  "enable": true,
  "key": ["sk-YourKey"],
  "api_base": "https://api.deepseek.com/v1",
  "model": "deepseek-flash",
  "provider_source_id": "deepseek",
  "modalities": ["text", "image", "tool_use"]
}

4.2 Web Search

DeepSeek has a built-in search API; reuse the same key:

curl -X POST https://api.deepseek.com/v1/tools/search \
  -H "Authorization: Bearer sk-YourKey" \
  -H "Content-Type: application/json" \
  -d '{"query":"Today's AI news","count":3,"need_content":false}'

Enable it in AstrBot:

{
  "provider_settings": {
    "web_search": true,
    "websearch_provider": "deepseek",
    "websearch_deepseek_key": ["sk-YourKey"],
    "web_search_link": true
  }
}

4.3 Big Pitfall: Omitting modalities Causes 422

This is one of the most worth recording problems in this article.

Symptom:

422 UnprocessableEntityError:
Failed to deserialize the JSON body into the target type:
messages[45]: unknown variant `input_audio`,
expected one of `text`, `image_url`, `file`

Cause: AstrBot's context cleaning logic is:

modalities = provider.provider_config.get("modalities", None)
if not modalities:
    # Unconfigured defaults to support all modalities
    return contexts

In other words: when a provider doesn't declare capability boundaries, AstrBot defaults to supporting all modalities, so it stuffs raw audio into the request, and DeepSeek returns 422.

Fix: Explicitly declare only text / image / tool_use, no audio:

"modalities": ["text", "image", "tool_use"]

This way, even if speech transcription fails, the audio is replaced with an [Audio] placeholder text rather than making the whole request fail.


V. Group Chat Wake Control: Who Can Skip the @

5.1 Requirement

5.2 Principle

AstrBot's wake logic is hardcoded in waking_check/stage.py; it only recognizes @, @all, replies, and wake words. There's no per-person switch in the config layer.

But there's a key line: a plugin-registered handler sets is_wake to True. So you can:

  1. Set wake_prefix to an empty string [""], so all group messages enter the processing pipeline
  2. Write a plugin to act as a "gate": for non-whitelist senders who didn't @ and didn't call the name, call should_call_llm(False)

5.3 Plugin Implementation

from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star, register
from astrbot.core.message.components import At, Reply

WHITELIST = {"136988xxxxx"}
NAME_KEYWORDS = ("大肥鱼", "肥鱼", "邪恶大肥鱼")


@register("wake_by_user", "you", "Only specified QQ avoids @", "1.0.0")
class WakeByUser(Star):
    def __init__(self, context: Context):
        super().__init__(context)

    @filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
    async def gate(self, event: AstrMessageEvent):
        sender = str(event.get_sender_id())
        if sender in WHITELIST:
            return
        text = (event.message_str or "").strip()
        if any(k in text for k in NAME_KEYWORDS):
            return
        self_id = str(event.get_self_id())
        for seg in event.get_messages():
            if isinstance(seg, At) and str(getattr(seg, "qq", "")) == self_id:
                return
            if isinstance(seg, Reply) and str(getattr(seg, "sender_id", "")) == self_id:
                return
        event.should_call_llm(False)

5.4 Context Memory

If the group only records messages that @ the bot, it can't understand context. So you need to also enable:

{
  "provider_ltm_settings": {
    "group_message_history_enable": true,
    "group_message_history_max_cnt": 700
  }
}

VI. Voice Pipeline: Local Recognition + Girl-Voice Reply

This is the most troublesome part of the whole project; I'll describe it in four sections.

6.1 Local STT: Whisper Installation Traps

AstrBot's Whisper(Local) provider depends on openai-whisper, which in turn depends on torch.

Trap 1: PyPI's torch is the CUDA version, nearly 1GB, extremely slow to download in China (measured 40 minutes for 390MB).

Fix: Use Alibaba Cloud's CPU-only wheel (~200MB, 4.8MB/s):

pip install --target=/AstrBot/data/pylibs --no-deps \
  "https://mirrors.aliyun.com/pytorch-wheels/cpu/torch-2.6.0%2Bcpu-cp312-cp312-linux_x86_64.whl"

Trap 2: Direct pip install openai-whisper re-resolves dependencies and may pull the CUDA torch again.

Fix: Use --no-deps and install dependencies individually from a domestic mirror:

pip install --target=/AstrBot/data/pylibs --no-deps -i https://pypi.tuna.tsinghua.edu.cn/simple \
  openai-whisper
pip install --target=/AstrBot/data/pylibs -i https://pypi.tuna.tsinghua.edu.cn/simple \
  filelock typing-extensions sympy networkx jinja2 fsspec numpy numba tiktoken more-itertools tqdm

Trap 3: Packages installed with --target aren't importable by the main process by default.

Fix: Put a .pth file into the system site-packages (easier than rebuilding the container):

printf '/AstrBot/data/pylibs\n/AstrBot/data/pylibs-tts\n' \
  > /usr/local/lib/python3.12/site-packages/astrbot-extra.pth

6.2 Recognition Inaccuracy? Two Real Culprits

Culprit One: Language auto-detection goes off the rails.

When Whisper isn't told the language, it auto-detects. With low volume or blur, Chinese gets recognized as English or even Swedish:

Speech-to-text result: You thinkер y
Speech-to-text result: Så är jag.

Fix: Patch the provider to force Chinese and add a guiding prompt:

result = await loop.run_in_executor(
    None,
    partial(self.model.transcribe,
            language="zh",
            initial_prompt="以下是普通话日常对话。"),
    str(audio.path),
)

Culprit Two: QQ voice clips are too quiet.

Measured average user voice volume is -36.9 dB (normal speech is around -20 dB), so Whisper often outputs an empty string.

Fix: Do loudness normalization (loudnorm) before transcription:

proc = await asyncio.create_subprocess_exec(
    "ffmpeg", "-y", "-loglevel", "error",
    "-i", src,
    "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
    norm,
)
await proc.communicate()
target = norm if proc.returncode == 0 else src

Effect comparison:

Audio Before fix After fix
Normal volume Correct Correct
Volume reduced to 12% Became foreign language Still correct
Actual -36dB voice Empty string "我们就回来啦"

Fallback: If after normalization it's still empty, return a placeholder text to prevent audio from flowing to a model that doesn't support audio:

text = result["text"].strip()
return text if text else "(语音没听清,请再说一遍)"

6.3 Edge TTS: Free but Requires Internet

{
  "id": "edge_tts",
  "type": "edge_tts",
  "provider": "microsoft",
  "provider_type": "text_to_speech",
  "enable": true,
  "edge-tts-voice": "zh-CN-XiaoyiNeural",
  "rate": "+0%",
  "volume": "+0%",
  "pitch": "+0Hz"
}

For a fully offline cartoonish female voice, Linux options (GPT-SoVITS, Breezy, etc.) either need a GPU or have multi-GB models; poor cost-performance, so Edge is the final choice.

6.4 Voice Gating: Only Reply by Voice in Private Chat, and Only When Voice Is Sent

Requirement is clear: always text in groups; private chat text replies with text; private chat voice replies with voice.

AstrBot's TTS switch is per-session, so write a plugin to set it dynamically per message:

@filter.event_message_type(filter.EventMessageType.ALL)
async def gate(self, event: AstrMessageEvent):
    sender = str(event.get_sender_id())
    enable = bool(
        sender == OWNER
        and event.is_private_chat()
        and _has_voice(event)
    )
    await SessionServiceManager.set_tts_status_for_session(
        event.unified_msg_origin, enable
    )

Key pitfall: AstrBot's STT stage replaces Record components with Plain text, so at the plugin stage isinstance(m, Record) always returns False.

Fix: Check the raw event alongside:

def _has_voice(event) -> bool:
    # 1) current pipeline (before STT)
    if any(isinstance(m, Record) for m in event.get_messages()):
        return True
    # 2) original OneBot event (fallback)
    raw = getattr(event.message_obj, "raw_message", None)
    msg = getattr(raw, "message", None) or (raw.get("message") if isinstance(raw, dict) else None)
    if isinstance(msg, list):
        for seg in msg:
            t = seg.get("type") if isinstance(seg, dict) else getattr(seg, "type", None)
            if t == "record" or "record" in str(seg).lower():
                return True
    # 3) CQ string form
    if isinstance(getattr(raw, "raw_message", ""), str) and "[CQ:record" in raw.raw_message:
        return True
    return False

6.5 TTS Reads Out Symbols

Model replies include emoji and markdown (咕噜……本鱼**最爱**吃宵夜🐟🍜), and TTS faithfully reads them aloud.

Fix: Clean the text before synthesis:

_EMOJI_RE = re.compile(
    r"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\u2190-\u21FF\u2B00-\u2BFF\uFE0F\u200D]+"
)
_MD_RE = re.compile(r"[*_`#>~\[\](){}|\\]+")
_ELLIPSIS_RE = re.compile(r"…+|\.{3,}")

def _clean_for_tts(text: str) -> str:
    t = _EMOJI_RE.sub("", text or "")
    t = _MD_RE.sub("", t)
    t = _ELLIPSIS_RE.sub(",", t)
    return re.sub(r",{2,}", ",", t).strip(" ,\n")

Measured result:

Original:  咕噜……本鱼**最爱**吃宵夜🐟🍜!还有 #标签 和 [链接]
Cleaned:  咕噜,本鱼最爱吃宵夜!还有 标签 和 链接

VII. Making the Bot Call an Agent: DSH Integration

7.1 Why Use headless

DSH (DeepSeek Harness) has a one-shot execution mode:

dsh --profile headless "帮我查一下今天的 AI 新闻并总结"
# executes and prints the result to stdout, then exits

This fits the "QQ message → subprocess → get result" model perfectly.

7.2 How to Run Node Inside the Container

DSH requires Node 24. After installing it on the server (~120MB), mount it read-only into the AstrBot container:

docker run -d --name astrbot ... \
  -v /opt/node:/opt/node:ro \
  soulter/astrbot:latest

Verify inside the container:

docker exec astrbot /opt/node/bin/dsh --version
# 0.2.1-rc.2

Install npm packages from the official mirror: npm install -g @deepseek-ai/dsh --registry=https://registry.npmmirror.com
Note that npm 11 blocks install scripts, so you need --allow-scripts.

7.3 Plugin Design: Whitelist + Approval

OWNER = "1369xxxxx"
DSH_BIN = "/opt/node/bin/dsh"
WORKDIR = "/AstrBot/data/dsh-work"
TIMEOUT = 600
PENDING = {}   # approval queue
LAST = {}      # last task context

# trigger words: dsh / tool / message mentions Feishu
# Owner: execute directly
# Others: generate a number, send privately to owner for approval

Approval flow:

Passerby: 工具 帮我写个脚本
Bot: 已提交管理员审批(编号 1)
Owner (private): 同意 1
Bot → passerby: execution result
Bot → owner: 【1 完成】summary

7.4 Engineering Details

① Context continuation

After an agent task, if the user follows up with "不对,应该放到表格里" (that's wrong, put it in the table), it lands in the normal chat model, which has no idea what DSH did.

Fix: Automatically bring