W1-W7 都在企业"内"转。企微的另一半价值在外:客户的微信里躺着你销售的好友位,这就是"客户联系"体系。运营的第一战场在加好友后的第一句话——官方机制把这个战场设计成了一场限时赛。

一、接口速览表

项官方事实(2026-11-05 核对)
触发事件change_external_contact,ChangeType=add_external_contact(成员加客户)/ add_half_external_contact(客户免验证加成员)
回调字段UserID(成员)、ExternalUserID(客户,不是企业成员账号)、State(「联系我」配置的渠道参数)、WelcomeCode(发欢迎语的凭证)
欢迎语接口POST externalcontact/send_welcome_msg,参数 welcome_code + text + attachments(新版支持 9 个附件含视频)
20 秒窗口官方原文:“企业仅可在收到相关事件后 20 秒内调用,且只可调用一次”
竞态码41051=欢迎语已下发(客户已开始聊天等,勿重试);41096=其他应用正在发(可重试——两码处理方向相反)
不返回 code 的情形管理端已配默认欢迎语 / 客户已聊天 / 商务伙伴自动递名片——代码必须容忍"没有 welcome_code"
客户详情get_externalcontact/get 带 external_userid;跟进人 >500 需 cursor 分页;follow_user.state 可回溯渠道
权限自建应用要配到「客户联系-可调用接口的应用」;unionid 需绑定微信开发者 ID 才有
数据红线remark_mobiles、avatar、gender、unionid 等字段第三方/代开发应用不可获取(官方硬隔离)

二、20 秒窗口的工程含义:AI 必须"预备答题"

大模型现场生成一段欢迎语,快则 3-5 秒、慢则 10 秒+,再算上网络往返,裸调模型大概率超窗。正确架构是"两段式":

  1. 预生成:按渠道(State 参数标识:获客链接/展会二维码/视频号…)提前用 AI 批量写好欢迎语模板池,缓存
  2. 临场:事件到达 → 查渠道取模板 + 变量填充(本地毫秒级)→ 20 秒倒计时内发出
  3. 降级:临场生成超时或无 welcome_code → 放弃个性化,什么都不发(管理端默认欢迎语兜底)

把 LLM 放在倒计时前面而不是里面,这是全系列里最典型的"延迟预算倒推架构"。

三、完整代码(事件解析 + 欢迎语装配 + 超时降级,实测跑通)

"""客户联系欢迎语链路:事件解析→渠道匹配→20秒窗口守卫→竞态码处理(本地模拟,dry-run)"""
import json, re, time

WELCOME_WINDOW = 20.0          # 官方:收到事件后20秒内、仅一次
ERR_ALREADY = 41051            # 已下发:勿重试
ERR_RACING  = 41096            # 他应用正在发:可重试

def parse_event(xml_text):
    g = lambda tag: (re.search(r'<%s><!\[CDATA\[(.*?)\]\]></%s>' % (tag, tag), xml_text)
                     or re.search(r'<%s>(.*?)</%s>' % (tag, xml_text) , xml_text))
    d = {t: (g(t).group(1) if g(t) else '') for t in
         ('Event', 'ChangeType', 'UserID', 'ExternalUserID', 'State', 'WelcomeCode')}
    if d['Event'] != 'change_external_contact':
        raise ValueError('非客户联系事件')
    return d

# 渠道→预生成欢迎语池(上线前由 AI 按渠道批量写好,事件到达只做选择与变量填充)
POOL = {
    'expo_qr':  '王工您好,我是展会上聊过产线的{{name}}团队。附件是方案与案例,约个演示随时说。',
    'link_ad':  '您好,从介绍页过来的话,{{name}}这边今天可安排产品顾问,先问您三个场景问题。',
    '':         None,                         # 无渠道标记→不发个性化,交给默认欢迎语
}

def build_welcome(ev, now, gen_deadline=None):
    """返回发送动作或降级说明。gen_deadline=AI临场生成路径的最晚可用时刻。"""
    wc = ev['WelcomeCode']
    if not wc:
        return {'skip': '无 welcome_code(默认欢迎语已配置/客户已聊天/名片自动递)'}
    age = now - ev['_arrived_at']
    if age > WELCOME_WINDOW:
        return {'skip': '超20秒窗口(官方硬限),放弃个性化,默认话术兜底'}
    tpl = POOL.get(ev['State'])
    if tpl is None:
        return {'skip': '未知渠道 State=%r,不发个性化避免说错话' % ev['State']}
    text = tpl.replace('{{name}}', '李')       # 变量填充本地毫秒级,不等LLM
    return {'send': {'welcome_code': wc, 'text': {'content': text},
                     'attachments': []},
            'deadline_left': '%.1fs' % (WELCOME_WINDOW - age)}

def handle_resp(r, retry_left=1):
    """发送结果处理:41051/41096 方向相反,别写反。"""
    code = r.get('errcode')
    if code == 0:
        return '已送达'
    if code == ERR_ALREADY:
        return '41051 客户已开始聊天,欢迎语已由他方下发——勿重试'
    if code == ERR_RACING and retry_left > 0:
        return '41096 他应用抢占中——可重试(剩%d次)' % retry_left
    return 'errcode=%s 记录待查' % code

if __name__ == '__main__':
    now = time.time()
    cb = ('<xml><Event><![CDATA[change_external_contact]]></Event>'
          '<ChangeType><![CDATA[add_external_contact]]></ChangeType>'
          '<UserID><![CDATA[sales_wang]]></UserID>'
          '<ExternalUserID><![CDATA[wmAbc123]]></ExternalUserID>'
          '<State><![CDATA[expo_qr]]></State>'
          '<WelcomeCode><![CDATA[wc_20261105_001]]></WelcomeCode></xml>')
    ev = parse_event(cb); ev['_arrived_at'] = now
    act = build_welcome(ev, now)
    print('① 正常链路:', json.dumps(act, ensure_ascii=False)[:150])
    # ② 超窗事件
    late = dict(ev); late['_arrived_at'] = now - 25
    print('② 迟到25秒:', build_welcome(late, now)['skip'])
    # ③ 无 welcome_code
    nc = dict(ev); nc['WelcomeCode'] = ''
    print('③ 无code:', build_welcome(nc, now)['skip'])
    # ④ 未知渠道
    uk = dict(ev); uk['State'] = 'weird_channel'
    print('④ 未知渠道:', build_welcome(uk, now)['skip'])
    # ⑤ 竞态码处理
    print('⑤', handle_resp({'errcode': 41051}), '/', handle_resp({'errcode': 41096}))

实测输出:

① 正常链路: {"send": {"welcome_code": "wc_20261105_001", "text": {"content": "王工您好,我是展会上聊过产线的李团队。附件是方案与案例,约个演示随时说。"}, "attachments": []}, "deadline_left": "20.0s"}
② 迟到25秒: 超20秒窗口(官方硬限),放弃个性化,默认话术兜底
③ 无code: 无 welcome_code(默认欢迎语已配置/客户已聊天/名片自动递)
④ 未知渠道: 未知渠道 State='weird_channel',不发个性化避免说错话
⑤ 41051 客户已开始聊天,欢迎语已由他方下发——勿重试 / 41096 他应用抢占中——可重试(剩1次)

五条路径各有出口,核心原则一条:任何不确定,宁可让默认欢迎语兜底,也不发错话——欢迎语直达客户,是 AI 应用里少数"错了会被客户看见"的链路。

四、预生成池的可复制提示词(AI 批量写渠道欢迎语)

你是企微客户欢迎语写手。为渠道「{{channel}}」写3条备选欢迎语,存进预生成池:
硬约束:
1. 每条 ≤120 汉字(welcome text 会展示在聊天里,长了没人看)
2. 必须包含一个开放式提问或低门槛动作("约演示"/"回个1"),结尾不追问
3. 只允许用输入里出现的事实:渠道场景、我方业务、客户可能身份;
   没有的信息用{{变量}}占位,不许编"贵司作为行业领军企业"式套话
4. 禁用词表:您好打扰了/亲/福利/优惠/限时/免费领
渠道场景描述:{{desc}}   我方业务一句话:{{biz}}
输出 JSON:[{"text":"...","use_when":"什么信号出现用这条"}]

这条是"离线批量生成",不受 20 秒限制——把创意压力从倒计时里挪出来,就是第二节的架构思想落到提示词层。

五、错误码表

errcode含义动作
41051欢迎语已下发(客户已聊天)勿重试
41096多应用抢占,他方正在发可重试(与上条方向相反,写反即事故)
93000 系external_userid 不合法/不在范围核对客户与成员关系,重试无意义
事件无 WelcomeCode默认欢迎语已配/已聊天/自动递名片正常分支,静默跳过

六、系列收官:企业微信 × AI 的完整地图

步骤能力一句话
W1消息→审批单双输入+控件序列化,AI 填单不编造
W2审批→群播报webhook 模板卡片,状态自动回群
W3access_token缓存+提前刷新+失效重试,全体系的底座
W4应用消息定向到人+配额预算,机器人管不到的地方
W5接收消息验签+AES 解密+排重,对话入口
W6卡片交互EventKey/ResponseCode 状态机,一键审批
W7智能机器人stream 全量刷新,大模型原生流式
W8客户联系welcome_code 20 秒窗口,对客第一句话

八步连起来:对内(审批/消息/机器人)+ 对外(客户欢迎语)的双向通路,每一步的接口事实都对齐了官方文档 2026-11-05 版。

七、参考与说明

本文系列涉及的提示词模板与配套教程合集,收录于提示岛(prompts.ssjintaitong.com)。

接口原文在企业微信开发者中心官方文档(管理后台侧栏可进入,或搜"发送新客户欢迎语"“客户联系事件格式”“获取客户详情”),本文 20 秒窗口、41051/41096 语义、不返回 welcome_code 的三种情形、State/unionid/字段隔离规则均以 2026-11-05 官方页面为准;第三节代码实测跑通(输出见正文)。


change_external_contact事件字段/WelcomeCode一次性20秒窗口/41051勿重试与41096可重试/无code三种情形/get详情cursor分页500人/第三方应用字段不可获取清单——逐条摘自企业微信开发者中心官方文档 2026-11-05 核对原文;代码本地真跑;示例客户/欢迎语池均为虚构占位。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐