refactor: migrate workflow to v2 core and archive legacy modules
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,300 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
每日聊天汇总定时任务
|
||||
- 每天 23:50 自动统计当日各店铺数据
|
||||
- 用 AI 生成自然语言摘要
|
||||
- 发送到企业微信 Webhook + QQ 邮件
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
|
||||
SUMMARY_EMAIL = os.getenv("SUMMARY_EMAIL", "") # 收摘要的邮箱
|
||||
SEND_HOUR = int(os.getenv("SUMMARY_HOUR", "23"))
|
||||
SEND_MINUTE = int(os.getenv("SUMMARY_MINUTE", "50"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 统计数据整理
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def _build_stats_text(target_date: str = "") -> str:
|
||||
"""整理今日数据,返回给 AI 的原始统计文本"""
|
||||
from db import chat_log_db as db
|
||||
from db.deal_outcome_db import get_daily_summary
|
||||
|
||||
if not target_date:
|
||||
target_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
stats = db.get_daily_stats(target_date)
|
||||
convs = db.get_daily_conversations(target_date)
|
||||
deal_sum = get_daily_summary(target_date)
|
||||
|
||||
if not stats:
|
||||
return f"{target_date} 当日无任何聊天记录。"
|
||||
|
||||
# 按 acc_id 分组对话片段
|
||||
conv_map: dict[str, list] = {}
|
||||
for c in convs:
|
||||
aid = c.get("acc_id") or "未知店铺"
|
||||
conv_map.setdefault(aid, []).append(c)
|
||||
|
||||
lines = [f"【{target_date} 各店铺数据】\n"]
|
||||
|
||||
# 成交/未成交汇总(供 AI 摘要与数据分析)
|
||||
lines.append("【成交与未成交】")
|
||||
lines.append(f" 成交:{deal_sum['成交数']} 笔,金额 {deal_sum['成交金额']:.0f} 元")
|
||||
lines.append(f" 未成交:{deal_sum['未成交数']} 笔")
|
||||
if deal_sum["未成交原因分布"]:
|
||||
for reason, cnt in deal_sum["未成交原因分布"].items():
|
||||
lines.append(f" - {reason}:{cnt} 笔")
|
||||
if deal_sum["成交明细"]:
|
||||
for o in deal_sum["成交明细"][:5]:
|
||||
r = "让价后" if o.get("discount_given") else "直接"
|
||||
lines.append(f" ✓ {o.get('customer_name', '')[:6]} {r}成交 {o.get('amount', 0):.0f}元")
|
||||
if deal_sum["未成交明细"]:
|
||||
for o in deal_sum["未成交明细"][:5]:
|
||||
lines.append(f" ✗ {o.get('customer_name', '')[:6]} {o.get('reason', '')}")
|
||||
lines.append("")
|
||||
|
||||
for s in stats:
|
||||
acc = s.get("acc_id") or "未知店铺"
|
||||
plat = s.get("platform") or ""
|
||||
label = f"{acc}({plat})" if plat else acc
|
||||
|
||||
lines.append(f"▶ 店铺:{label}")
|
||||
lines.append(f" 接待客户:{s['unique_customers']} 人,共 {s['total_msgs']} 条消息(收 {s['recv']} 发 {s['sent']})")
|
||||
lines.append(f" 首条:{(s.get('first_msg') or '')[-8:-3]} 末条:{(s.get('last_msg') or '')[-8:-3]}")
|
||||
|
||||
shop_convs = conv_map.get(acc, [])
|
||||
for c in shop_convs[:6]: # 最多展示6个客户片段
|
||||
name = c.get("customer_name") or c.get("customer_id", "")[:8]
|
||||
snippet = (c.get("snippet") or "")[:120]
|
||||
lines.append(f" · {name}({c['msg_count']}条){snippet}")
|
||||
if len(shop_convs) > 6:
|
||||
lines.append(f" ... 还有 {len(shop_convs)-6} 位客户")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# AI 生成摘要
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def _ai_summary(raw_text: str) -> str:
|
||||
"""调用 AI 把统计文本转成自然语言日报"""
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_BASE_URL"),
|
||||
)
|
||||
model = os.getenv("OPENAI_MODEL", "doubao-seed-2-0-lite-260215")
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是一名电商运营助理。根据下面的客服聊天数据,"
|
||||
"为老板写一份简洁的当日运营日报(200字以内)。"
|
||||
"要包含:接待总人数、各店铺情况、有无成交或异常情况。"
|
||||
"语气轻松,像发给老板的微信消息,不需要标题。"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": raw_text},
|
||||
],
|
||||
max_tokens=300,
|
||||
temperature=0.5,
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
# AI 失败就直接返回原始统计
|
||||
return raw_text
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 推送:企业微信
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def _send_wechat(content: str):
|
||||
"""推送到企业微信群机器人(markdown 格式,单条 ≤4096 字节自动分段)"""
|
||||
if not WECHAT_WEBHOOK:
|
||||
logger.info("[DailySummary] 未配置 WECHAT_WEBHOOK,跳过推送")
|
||||
return
|
||||
|
||||
# 企业微信单条 markdown 限 4096 字节,超长自动分段
|
||||
encoded = content.encode("utf-8")
|
||||
chunks = []
|
||||
while encoded:
|
||||
chunk = encoded[:3800].decode("utf-8", errors="ignore")
|
||||
chunks.append(chunk)
|
||||
encoded = encoded[len(chunk.encode("utf-8")):]
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for i, chunk in enumerate(chunks):
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": chunk}}
|
||||
try:
|
||||
resp = await client.post(WECHAT_WEBHOOK, json=payload)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
logger.info("[DailySummary] 企业微信推送成功(第%s段)", i + 1)
|
||||
else:
|
||||
logger.warning("[DailySummary] 企业微信推送失败: %s", data)
|
||||
except Exception as e:
|
||||
logger.exception("[DailySummary] 企业微信推送异常: %s", e)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 推送:邮件
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def _send_email(subject: str, body: str):
|
||||
"""发送日报邮件"""
|
||||
if not SUMMARY_EMAIL:
|
||||
return
|
||||
try:
|
||||
from mail.email_sender import email_sender
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.header import Header
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = Header(subject, "utf-8").encode()
|
||||
msg["From"] = f"{Header(email_sender.sender_name, 'utf-8').encode()} <{email_sender.smtp_user}>"
|
||||
msg["To"] = SUMMARY_EMAIL
|
||||
|
||||
with smtplib.SMTP(email_sender.smtp_host, email_sender.smtp_port) as s:
|
||||
s.starttls()
|
||||
s.login(email_sender.smtp_user, email_sender.smtp_password)
|
||||
s.sendmail(email_sender.smtp_user, [SUMMARY_EMAIL], msg.as_string())
|
||||
logger.info("[DailySummary] 日报邮件已发送至 %s", SUMMARY_EMAIL)
|
||||
except Exception as e:
|
||||
logger.exception("[DailySummary] 日报邮件发送失败: %s", e)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 企业微信 Markdown 排版
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
def _build_wechat_markdown(title: str, ai_text: str, raw_text: str, target_date: str = "") -> str:
|
||||
"""
|
||||
构建符合企业微信规范的 markdown 内容。
|
||||
支持:**bold**、<font color="...">、> 引用、``` 代码块、- 列表
|
||||
不支持:<details>、<summary>、HTML 标签(除 font/br)
|
||||
"""
|
||||
from db import chat_log_db as db
|
||||
from db.deal_outcome_db import get_daily_summary
|
||||
date = target_date or datetime.now().strftime("%Y-%m-%d")
|
||||
stats = db.get_daily_stats(date)
|
||||
deal_sum = get_daily_summary(date)
|
||||
|
||||
lines = [f"## {title}\n"]
|
||||
|
||||
# AI 摘要部分
|
||||
lines.append("> " + ai_text.replace("\n", "\n> "))
|
||||
lines.append("")
|
||||
|
||||
# 成交/未成交
|
||||
lines.append("**📈 成交与未成交**")
|
||||
lines.append(f"- 成交 **{deal_sum['成交数']}** 笔 · 金额 **{deal_sum['成交金额']:.0f}** 元")
|
||||
lines.append(f"- 未成交 **{deal_sum['未成交数']}** 笔")
|
||||
if deal_sum["未成交原因分布"]:
|
||||
for reason, cnt in deal_sum["未成交原因分布"].items():
|
||||
lines.append(f" - {reason}:{cnt} 笔")
|
||||
lines.append("")
|
||||
|
||||
# 各店铺数据表格(企业微信不支持 | 表格,用列表代替)
|
||||
if stats:
|
||||
lines.append("**📋 各店铺明细**")
|
||||
for s in stats:
|
||||
acc = s.get("acc_id") or "未知店铺"
|
||||
plat = s.get("platform") or ""
|
||||
label = f"{acc}({plat})" if plat else acc
|
||||
first = (s.get("first_msg") or "")[-8:-3]
|
||||
last = (s.get("last_msg") or "")[-8:-3]
|
||||
lines.append(
|
||||
f"- <font color=\"info\">{label}</font> "
|
||||
f"接待 **{s['unique_customers']}** 人 · "
|
||||
f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']})"
|
||||
f" {first}~{last}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"<font color=\"comment\">发送时间:{datetime.now().strftime('%H:%M:%S')}</font>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 主入口:生成并推送日报
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def send_daily_summary(target_date: str = ""):
|
||||
"""生成并推送当日汇总"""
|
||||
if not target_date:
|
||||
target_date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
logger.info("[DailySummary] 开始生成 %s 日报...", target_date)
|
||||
|
||||
raw_text = _build_stats_text(target_date)
|
||||
ai_text = await _ai_summary(raw_text)
|
||||
title = f"📊 {target_date} 客服日报"
|
||||
|
||||
# ── 企业微信 markdown(不支持 <details>,用标准语法)──
|
||||
wechat_md = _build_wechat_markdown(title, ai_text, raw_text, target_date)
|
||||
await _send_wechat(wechat_md)
|
||||
|
||||
# ── 邮件:纯文本 ──
|
||||
email_body = f"{ai_text}\n\n{'='*40}\n\n{raw_text}"
|
||||
_send_email(title, email_body)
|
||||
|
||||
logger.info("[DailySummary] 日报推送完成")
|
||||
return ai_text
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 定时调度(由 websocket_client 启动)
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def scheduler():
|
||||
"""每天 SEND_HOUR:SEND_MINUTE 触发日报"""
|
||||
logger.info("[DailySummary] 定时日报已启动,发送时间 %02d:%02d", SEND_HOUR, SEND_MINUTE)
|
||||
sent_today: Optional[str] = None # 记录已发日期,防重复
|
||||
|
||||
while True:
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
|
||||
if now.hour == SEND_HOUR and now.minute == SEND_MINUTE and sent_today != today:
|
||||
sent_today = today
|
||||
try:
|
||||
await send_daily_summary(today)
|
||||
except Exception as e:
|
||||
logger.exception("[DailySummary] 日报生成出错: %s", e)
|
||||
|
||||
# 每 30 秒检查一次
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 命令行手动触发
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
result = asyncio.run(send_daily_summary(target))
|
||||
logger.info("\n=== AI 摘要 ===")
|
||||
logger.info(result)
|
||||
@@ -1,159 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
语义匹配 - 用 embedding 做意图/情绪识别
|
||||
配置 EMBEDDING_MODEL 后启用,否则回退到关键词
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 意图模板(用于 embedding 相似度匹配)
|
||||
INTENT_TEMPLATES = {
|
||||
"询价": "我想问一下价格多少钱",
|
||||
"发图": "我发图给你看看",
|
||||
"砍价": "能不能便宜点太贵了",
|
||||
"批量": "我要做很多张图批量",
|
||||
"加急": "能不能快点很急",
|
||||
"售后": "已经付款了什么时候好",
|
||||
"修改": "不满意要改一下",
|
||||
"转接": "我要退款投诉",
|
||||
"打招呼": "你好在吗有人吗",
|
||||
}
|
||||
EMOTION_TEMPLATES = {
|
||||
"平静": "好的谢谢",
|
||||
"着急": "快点啊很急",
|
||||
"不满": "怎么这么慢不满意",
|
||||
"砍价": "太贵了便宜点",
|
||||
}
|
||||
|
||||
|
||||
_template_embeddings: dict = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentDecision:
|
||||
intent: str = ""
|
||||
source: str = "none" # embedding / keyword / none
|
||||
score: float = 0.0
|
||||
|
||||
def _get_embedding(text: str, cache_key: str = None) -> Optional[list]:
|
||||
"""调用 embedding API,失败返回 None。cache_key 用于缓存模板向量"""
|
||||
model = os.getenv("EMBEDDING_MODEL", "")
|
||||
if not model:
|
||||
return None
|
||||
if cache_key and cache_key in _template_embeddings:
|
||||
return _template_embeddings[cache_key]
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_BASE_URL"),
|
||||
)
|
||||
resp = client.embeddings.create(model=model, input=text[:2000])
|
||||
emb = resp.data[0].embedding
|
||||
if cache_key:
|
||||
_template_embeddings[cache_key] = emb
|
||||
return emb
|
||||
except Exception as e:
|
||||
logger.debug(f"embedding 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _cosine_sim(a: list, b: list) -> float:
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = sum(x * x for x in a) ** 0.5
|
||||
nb = sum(y * y for y in b) ** 0.5
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def detect_intent_embedding(msg: str) -> Optional[str]:
|
||||
"""用 embedding 检测意图,未配置或失败返回 None。"""
|
||||
decision = detect_intent_embedding_decision(msg)
|
||||
return decision.intent or None
|
||||
|
||||
|
||||
def detect_intent_embedding_decision(msg: str) -> IntentDecision:
|
||||
"""返回 embedding 意图决策(含分值)。"""
|
||||
msg_emb = _get_embedding(msg)
|
||||
if not msg_emb:
|
||||
return IntentDecision()
|
||||
best_intent, best_score = "", 0.0
|
||||
for intent, template in INTENT_TEMPLATES.items():
|
||||
tpl_emb = _get_embedding(template, cache_key=f"intent_{intent}")
|
||||
if not tpl_emb:
|
||||
continue
|
||||
sim = _cosine_sim(msg_emb, tpl_emb)
|
||||
if sim > best_score:
|
||||
best_score = sim
|
||||
best_intent = intent
|
||||
if best_score > 0.6:
|
||||
return IntentDecision(intent=best_intent, source="embedding", score=float(best_score))
|
||||
return IntentDecision()
|
||||
|
||||
|
||||
def detect_emotion_embedding(msg: str) -> Optional[str]:
|
||||
"""用 embedding 检测情绪"""
|
||||
msg_emb = _get_embedding(msg)
|
||||
if not msg_emb:
|
||||
return None
|
||||
best_emotion, best_score = "", 0.0
|
||||
for emotion, template in EMOTION_TEMPLATES.items():
|
||||
tpl_emb = _get_embedding(template, cache_key=f"emotion_{emotion}")
|
||||
if not tpl_emb:
|
||||
continue
|
||||
sim = _cosine_sim(msg_emb, tpl_emb)
|
||||
if sim > best_score:
|
||||
best_score = sim
|
||||
best_emotion = emotion
|
||||
return best_emotion if best_score > 0.55 else None
|
||||
|
||||
|
||||
def detect_intent_keywords(msg: str) -> str:
|
||||
"""关键词回退:无 embedding 时使用"""
|
||||
m = (msg or "").strip().lower()
|
||||
if any(k in m for k in ["退款", "退货", "投诉"]):
|
||||
return "转接"
|
||||
if any(k in m for k in ["多张", "批量", "很多", "几十张"]):
|
||||
return "批量"
|
||||
if any(k in m for k in ["快点", "加急", "很急", "着急"]):
|
||||
return "加急"
|
||||
if any(k in m for k in ["便宜", "贵", "少点", "打折"]):
|
||||
return "砍价"
|
||||
if any(k in m for k in ["改", "修改", "不满意"]):
|
||||
return "修改"
|
||||
if any(k in m for k in ["多少钱", "价格", "报价", "多钱", "收费", "怎么收费", "咋收费"]):
|
||||
return "询价"
|
||||
if any(k in m for k in ["在吗", "你好", "有人"]):
|
||||
return "打招呼"
|
||||
return ""
|
||||
|
||||
|
||||
def detect_intent(msg: str) -> IntentDecision:
|
||||
"""
|
||||
AI 意图判定 + 规则兜底:
|
||||
1) 有 embedding 配置时先走 embedding。
|
||||
2) 失败/低置信时回退关键词规则。
|
||||
"""
|
||||
text = (msg or "").strip()
|
||||
if not text:
|
||||
return IntentDecision()
|
||||
|
||||
try:
|
||||
emb_decision = detect_intent_embedding_decision(text)
|
||||
except Exception:
|
||||
emb_decision = IntentDecision()
|
||||
if emb_decision.intent:
|
||||
return emb_decision
|
||||
|
||||
kw_intent = detect_intent_keywords(text)
|
||||
if kw_intent:
|
||||
return IntentDecision(intent=kw_intent, source="keyword", score=0.0)
|
||||
return IntentDecision()
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
客服对话推送到企业微信群 - 客户消息与AI回复成对发送,保持上下文
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
_last_push: dict[tuple[str, str], tuple[str, str, float]] = {}
|
||||
|
||||
def _get_webhook() -> str:
|
||||
"""优先从 config 读取,与健康检查/日报保持一致"""
|
||||
try:
|
||||
from config.config import WECHAT_WEBHOOK
|
||||
return WECHAT_WEBHOOK or os.getenv("WECHAT_WEBHOOK", "")
|
||||
except Exception:
|
||||
return os.getenv("WECHAT_WEBHOOK", "")
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int = 200) -> str:
|
||||
"""截断过长内容"""
|
||||
if not text:
|
||||
return ""
|
||||
text = str(text).strip()
|
||||
if len(text) > max_len:
|
||||
return text[:max_len] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _get_recent_conversation(customer_id: str, acc_id: str, last_n: int = 8) -> list:
|
||||
"""获取近期对话(同店铺),保持连贯上下文"""
|
||||
try:
|
||||
from db.chat_log_db import get_recent_conversation
|
||||
return get_recent_conversation(customer_id, acc_id, limit=last_n)
|
||||
except Exception:
|
||||
logger.debug("[WechatChatLog] 获取近期对话失败,返回空列表", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
async def push_chat_to_wechat(
|
||||
customer_name: str,
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
customer_msg: str,
|
||||
reply_msg: str,
|
||||
goods_name: str = "",
|
||||
):
|
||||
"""
|
||||
将客户消息与AI回复推送到企业微信群,附带近期对话保持连贯。
|
||||
"""
|
||||
webhook = _get_webhook()
|
||||
if not webhook:
|
||||
return
|
||||
# 去重:同一客户+店铺,若客户消息与回复完全相同且在窗口期内,则跳过
|
||||
try:
|
||||
import time
|
||||
key = (customer_id or "", acc_id or "")
|
||||
now = time.time()
|
||||
last = _last_push.get(key)
|
||||
if last:
|
||||
last_customer_msg, last_reply_msg, last_ts = last
|
||||
if (last_customer_msg or "") == (customer_msg or "") and (last_reply_msg or "") == (reply_msg or ""):
|
||||
if now - last_ts < 30:
|
||||
return
|
||||
_last_push[key] = ((customer_msg or ""), (reply_msg or ""), now)
|
||||
except Exception:
|
||||
logger.debug("[WechatChatLog] 去重检查异常,忽略本次去重", exc_info=True)
|
||||
reply_msg = _truncate(reply_msg, 300)
|
||||
ts = datetime.now().strftime("%H:%M")
|
||||
shop = acc_id or "未知店铺"
|
||||
name = (customer_name or customer_id or "客户")[:12]
|
||||
|
||||
lines = [f"**📩 {ts} | {shop}**"]
|
||||
if goods_name:
|
||||
lines.append(f"**商品** {_truncate(goods_name, 80)}")
|
||||
if customer_id:
|
||||
lines.append(f"**客户ID** {customer_id}")
|
||||
lines.append("")
|
||||
|
||||
# 附带近期对话,保持连贯
|
||||
recent = _get_recent_conversation(customer_id, acc_id, last_n=8)
|
||||
last_line = None
|
||||
for m in recent:
|
||||
role = customer_id if m.get("direction") == "in" else "客服"
|
||||
msg = _truncate((m.get("message") or "").strip(), 120)
|
||||
if msg:
|
||||
line = f"{role}:{msg}"
|
||||
# 防止日志中的重复记录在企微里连续刷屏
|
||||
if line == last_line:
|
||||
continue
|
||||
lines.append(line)
|
||||
last_line = line
|
||||
# 当前回复(可能已在 recent 中有客户消息,客服回复是新的)
|
||||
lines.append(f"客服:{reply_msg or '(无回复)'}")
|
||||
|
||||
content = "\n".join(lines)
|
||||
enc = content.encode("utf-8")
|
||||
if len(enc) > 3800:
|
||||
content = enc[:3750].decode("utf-8", errors="ignore") + "\n...(略)"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
resp = await client.post(
|
||||
webhook,
|
||||
json={"msgtype": "markdown", "markdown": {"content": content}},
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
return
|
||||
else:
|
||||
logger.warning("[WechatChatLog] 推送失败: %s", data)
|
||||
except Exception as e:
|
||||
logger.exception("[WechatChatLog] 推送异常: %s", e)
|
||||
|
||||
|
||||
async def send_morning_startup():
|
||||
"""每天早上8点发送客服启动消息到企微群"""
|
||||
webhook = _get_webhook()
|
||||
if not webhook:
|
||||
return
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
content = f"**☀️ 客服已启动**\n{ts}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
await client.post(
|
||||
webhook,
|
||||
json={"msgtype": "markdown", "markdown": {"content": content}},
|
||||
)
|
||||
logger.info("[WechatChatLog] 早8点启动消息已发送")
|
||||
except Exception as e:
|
||||
logger.exception("[WechatChatLog] 启动消息发送失败: %s", e)
|
||||
|
||||
|
||||
async def morning_startup_scheduler():
|
||||
"""每天 8:00 发送启动消息"""
|
||||
logger.info("[WechatChatLog] 早8点启动消息定时任务已启动")
|
||||
sent_today = None
|
||||
while True:
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
if now.hour == 8 and now.minute == 0 and sent_today != today:
|
||||
sent_today = today
|
||||
await send_morning_startup()
|
||||
await asyncio.sleep(30)
|
||||
Reference in New Issue
Block a user