
Byted Outbound Call
- 16 installs
- 411 repo stars
- Updated August 4, 2026
- bytedance/agentkit-samples
byted-outbound-call is a skill that places AI phone calls to reserve, modify, or cancel restaurant bookings on the user's behalf.
About
byted-outbound-call is an AI phone-calling skill that handles restaurant reservation, modification, and cancellation tasks so a user does not have to dial themselves. It classifies the request into reserve, modify, or cancel, validates the required fields for that scenario, asks the user for anything missing, then submits an outbound-call job and can query its status. It rejects any request outside restaurant reservation tasks.
- AI phone-calling agent that books, modifies, and cancels restaurant reservations without manual dialing
- Classifies intent, validates required fields, and prompts the user for missing details
- Submits and queries outbound-call jobs via Volcengine outbound-call scripts
Byted Outbound Call by the numbers
- 16 all-time installs (skills.sh)
- Ranked #1,389 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
byted-outbound-call capabilities & compatibility
Requires a Volcengine account with outbound-call line access and three pre-configured outbound Agents.
- Capabilities
- outbound calling · reservation automation · voice agent
- Use cases
- orchestration
- Pricing
- Bring your own API key
What byted-outbound-call says it does
AI-powered intelligent phone calling service to automatically handle restaurant reservation, modification, and cancellation tasks without manual dialing.
**范围校验**:仅接受餐厅的「预约/改订/取消」类任务,其他类型任务直接拒绝,返回拒绝原因
npx skills add https://github.com/bytedance/agentkit-samples --skill byted-outbound-callAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| repo stars | ★ 411 |
| Last updated | August 4, 2026 |
| Repository | bytedance/agentkit-samples ↗ |
What it does
Automate restaurant reservation, modification, and cancellation phone calls through an AI outbound-call agent.
Who is it for?
Automating restaurant reservation, change, and cancellation phone calls via an AI agent.
Skip if: Any non-restaurant call task, which the skill explicitly rejects.
When should I use this skill?
The user wants to reserve, modify, or cancel a restaurant booking by phone.
What you get
Submits an outbound-call job that reserves, modifies, or cancels a booking and reports the call result.
- A submitted outbound-call job with a JobId
- The call result and reservation summary on completion
By the numbers
- 3 scenarios (reserve, modify, cancel) each with a fixed script ID
Files
概述
目标用户
希望将生活服务场景中「需要打电话」的琐事外包给AI Agent的用户,适用场景目前只包含餐厅门店的预约、改订、取消。
注意事项
严禁修改byted-outbound-call下的代码。
---
核心用户故事,智能代打电话
1. 核心场景:作为不想自己打电话的用户,我可以直接用自然语言告诉ArkClaw要联系的门店、具体需求,调用流程:[二、外呼任务执行流程]。 2. 分步场景:
- 我可以直接说自然语言需求:「帮我联系A餐厅,电话13500015678,预约3月20日晚上7点的家庭聚餐,4个人」
- 当预订需要支付押金时,我会收到二次确认提示,确认后再继续执行
- 我能收到清晰的结果:成功时会返回完整的预订信息,失败时会告知具体原因,以及可选的替代方案
核心用户故事,查询外呼任务结果
1. 核心场景:用户主动的询问外呼任务状态,调用流程:[三、查询外呼任务状态流程]。
---
功能说明
一、前置准备(使用前需完成)
1. 开通火山引擎账号,完成外呼号线对接与资质备案 2. 在火山外呼平台创建3个专用Agent并配置对应的语音交互逻辑、音色、话术模板,每个场景对应固定剧本ID:
| 场景 | Agent名称 | 剧本ID |
|---|---|---|
| 新预约 | 预约Agent | llm_wlne_biffj |
| 修改已有预约 | 改订Agent | llm_eeac_bigci |
| 取消已有预约 | 取消Agent | llm_ivqy_bigcj |
3. 每个Agent配置对应的任务并发数,支持多任务并行执行
二、外呼任务执行流程
阶段1:任务创建与校验
1. 用户输入:用户用自然语言描述需求,无需固定格式 2. 范围校验:仅接受餐厅的「预约/改订/取消」类任务,其他类型任务直接拒绝,返回拒绝原因 3. 需求分类:自动识别用户需求是「预约/改订/取消」中的哪一类,匹配对应剧本ID 4. 信息校验与追问:校验对应场景的必填信息,任意一项缺失时自动用自然语言追问用户,直到信息补全:
| 需求类型 | 必填信息 | 对应参数名 |
|---|---|---|
| 预约 | 门店名称、门店电话、预约日期(年月日)、预约时间(时分)、人数、用户姓氏、用户联系电话 | shop_name/shop_phone_number/date/time/people_number/first_name/user_phone_number |
| 改订 | 门店名称、门店电话、原本预约的日期(年月日)、原本预约的时间(时分)、原本的人数、新预约日期(年月日)、新预约时间(时分)、新人数、用户姓氏、预订时预留的联系电话 | shop_name/shop_phone_number/date/time/people_number/new_date/new_time/new_people_number/first_name/user_phone_number |
| 取消 | 门店名称、门店电话、预约日期(年月日)、预约时间(时分)、人数、用户姓氏、预订时预留的联系电话 | shop_name/shop_phone_number/date/time/people_number/first_name/user_phone_number |
5. 押金二次确认:如果外呼前检测到该门店预订需要支付押金,自动向用户发送确认提示,用户确认后再继续执行 6. 异常处理:若本阶段任何步骤出现接口异常(code非200),直接将错误信息整理为自然语言告知用户,终止外呼流程,不进入后续提交步骤。
阶段2:必须携带的环境变量列表
当调用本技能的脚本时,下列所有的变量都必填,否则消息通知会报错,必须自动将当前会话的如下信息作为环境变量传递给exec命令。信息获取方式如下:
- OPENCLAW_CHANNEL:
- 含义: 当前消息渠道。
- 获取方式: 从消息元数据(metadata)中的
channel字段获取。 - OPENCLAW_SESSION_ID:
- 含义: 当前会话的chat_id。
- 获取方式: IF:
channel为webchat,则运行工具(tool)sessions_list,从sessions_list输出结果中获取当前对话的会话id(sessionId),并将sessionId赋值给OPENCLAW_SESSION_ID。ELSE: 将字符串赋值"no_need"赋值给OPENCLAW_SESSION_ID。 - OPENCLAW_USER_ID:
- 含义: 发送者用户ID。
- 获取方式: IF:
channel为webchat,将字符串赋值"no_need"赋值给OPENCLAW_USER_IDELSE: 将消息元数据(metadata)中的chat_id字段去除"user:"前缀,赋值给OPENCLAW_USER_ID。
阶段3:外呼任务提交
按如下步骤一步步执行: 1. 执行脚本:调用scripts/submit_job.py提交外呼任务,调用方式(Agent自动调用,不需要用户手动执行) 推荐方式:通过exec工具内置env参数传递(敏感信息不会出现在命令行历史) { "tool": "exec", "command": "python3 scripts/submit_job.py --request-body '{ "job_name": "【预约/改订/取消】+ 门店名称 + 用户需求", "job_description": "用户自然语言原始需求(可选)", "script_id": "对应场景的剧本ID:预约=llm_wlne_biffj/改订=llm_eeac_bigci/取消=llm_ivqy_bigcj", "params": { "user_phone_number": "联系电话(所有场景必填)", "date": "预约日期(仅预约场景)", "time": "预约时间(仅预约场景)", "people_number": "预约人数(仅预约场景)", "shop_name": "门店名称(所有场景必填)", "shop_phone_number": "门店电话(所有场景必填)", "first_name": "用户姓氏(所有场景必填)", "new_date": "新预约日期(仅改订场景)", "new_time": "新预约时间(仅改订场景)", "new_people_number": "新预约人数(仅改订场景)" } }' ", "env": { "OPENCLAW_SESSION_ID": "{{阶段2中获取到的OPENCLAW_SESSION_ID}}", "OPENCLAW_CHANNEL": "{{阶段2中获取到的OPENCLAW_CHANNEL}}", "OPENCLAW_USER_ID": "{{阶段2中获取到的OPENCLAW_USER_ID}}" } }
2. 查看上一步脚本的输出, 按如下要求进行处理: 1. IF 如果脚本输出内容里包含ERROR,将ERROR信息整理为自然语言告知用户,终止流程。 2. ELSE IF 如果脚本输出内容里没有ERROR,以比较友好的方式,把任务提交的参数和返回的JobId,整理为自然语言,在此环节告知用户任务已提交,在完成通知后终止流程。 参考返回样式:
- 任务提交成功返回示例:
✅ 已为你发起外呼任务「海底捞火锅(中关村店)」,程时间较长请耐心等待,任务完成后会通知你!
📋 任务ID:{JobId}
📅 时间:3月20日 19:00
👥 人数:4位
📝 预订人:李先生
三、查询外呼任务状态流程
1. 执行脚本:调用scripts/query_job.py查询外呼任务状态和详情,调用方式(Agent自动调用,不需要用户手动执行) { "tool": "exec", "command": "python3 scripts/query_job.py --job_id '{JobId}'" } 2. 查看上一步脚本的输出,脚本已自动整理好结果,按如下要求进行处理: 1. IF 如果脚本输出内容里包含ErrorCode,将错误信息整理为自然语言告知用户,终止流程。 2. ELSE IF 脚本输出已包含整理好的JobDetail信息:
- 脚本输出已包含:JobId、StatusDescription(状态描述)
- 若任务已完成(StatusDescription包含"已完成预订的呼叫"),还会包含:CallResultDescription(接通情况)、Summary(信息汇总)
- 直接将整理好的信息以友好的方式告知用户
参考返回样式:
- 查询等待中的任务返回示例:
📋 任务ID:{JobId}
⏳ 当前状态:呼叫任务已提交,正在排队等待系统发起呼叫。
- 查询处理中的任务返回示例:
📋 任务ID:{JobId}
🔄 当前状态:呼叫任务正在处理中,如商家未接听,系统会自动重呼,请耐心等待。
- 查询呼叫结束正在分析的任务返回示例:
📋 任务ID:{JobId}
📊 当前状态:所有预定的呼叫(包括重呼)均已执行完毕,正在分析通话结果。
- 查询已完成且接通的任务返回示例:
📋 任务ID:{JobId}
✅ 任务状态:已完成预订的呼叫,并且完成通话结果分析。
📞 接通情况:正常接通
预约详情:
> 📅 用餐时间:2026年3月28日 21:00
> 👥 人数:1位
> 📝 预订人:高先生
> 📞 预留电话:尾号 8838
- 查询已完成但未接通的任务返回示例:
📋 任务ID:{JobId}
✅ 任务状态:已完成预订的呼叫,并且完成通话结果分析。
📞 接通情况:无人接听
- 任务不存在返回示例:
❌ 查询失败:呼叫任务不存在,请确认任务ID是否正确
---
使用示例
示例1:完整信息直接执行
用户输入:帮我预约明天晚上7点的海底捞火锅中关村店,他们电话是13800138000,3个人,我姓王,联系电话13512345678
技能思考:用户query已经包含了门店名称、预约时间、预约人数、用户姓氏、联系电话等所有必要信息,无需追问,调用submit-job并通知客户
技能响应:好的,马上帮你联系海底捞火锅中关村店预约,完成后第一时间告诉你结果~
示例2:缺失信息自动追问
用户输入:帮我取消明天的海底捞预订
技能思考:用户query中包含了门店名称、时间,需要提供其他信息。
技能响应:请问海底捞的门店电话是多少?预订时预留的姓氏和联系电话是什么呢?
用户补全:电话是13500015678,我姓李,预留电话是13512345678
技能思考:用户query中已经包含所有必填信息,无需追问,直接调用cancel-job并通知客户。
技能响应:好的,马上帮你取消~
示例3:超出范围拒绝
用户输入:帮我给领导打个电话请假
>
技能响应:抱歉哦,我目前只支持生活服务类门店的预约、改订、取消类外呼任务,其他类型的通话需求暂时无法帮你处理~
示例4:查询外呼任务状态
用户输入:帮我查一下任务ID为 job_123456 的外呼任务状态
技能思考:用户需要查询指定JobId的任务状态,调用query_job脚本
技能响应:好的,马上帮你查询任务状态~
技能思考:执行查询脚本,获取已整理好的状态结果
技能响应:
> 📋 任务ID:job_123456
> ✅ 任务状态:已完成预订的呼叫,并且完成通话结果分析。
> 📞 接通情况:正常接通
> 预约详情:
> > 📅 用餐时间:2026年3月28日 21:00
> > 👥 人数:1位
> > 📝 预订人:高先生
> > 📞 预留电话:尾号 8838
---
错误处理
| 错误场景 | 处理逻辑 | 返回示例 |
|---|---|---|
| 商户电话无效/空号 | 直接返回失败,提示用户核对号码 | ❌ 拨打失败,你提供的门店电话135xxxxxxx是空号,请核对后重新提交 |
| 外呼接口调用失败 | 提取接口返回的msg信息整理后返回 | ❌ 外呼服务暂时不可用:接口返回「余额不足」,请充值后再试 |
| 任务提交异常 | 直接返回接口错误信息 | ❌ 任务提交失败:接口返回「剧本ID不存在」,请检查配置是否正确 |
| 环境变量校验失败 | 检查哪些变量没有传递,重试并传递进去 | 重试脚本 |
---
注意事项
1. 请确保提供的门店电话真实有效,否则会导致外呼失败 2. 涉及押金支付的场景会先经过用户二次确认,不会自动扣款
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
{
"name": "arkclaw-outbound-call",
"version": "1.0.0",
"description": "AI智能代打电话,自动办理生活服务场景的预约/改订/取消事务,无需用户手动拨打电话。支持用户通过job_id查询外呼任务状态",
"emoji": "📞",
"author": "ArkClaw Team",
"license": "MIT",
"type": "skill",
"userInvocable": true,
"argumentHint": "<自然语言描述需求>",
"pipRequirements": [
"requests>=2.31.0"
],
"requires": {
"env": [
"VOLCENGINE_ACCESS_KEY",
"VOLCENGINE_SECRET_KEY"
],
"bins": ["python3"]
},
"entry": "SKILL.md"
}# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""common 包初始化模块。"""
# 目前无需特殊初始化逻辑,保留该文件以确保 common 作为标准包可被导入。
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""统一的 Job API 响应解析工具。"""
from typing import Any, Dict, Tuple
ApiResponseTuple = Tuple[bool, str, str, Any, Dict[str, Any]]
def parse_api_response(response: Dict[str, Any]) -> ApiResponseTuple:
"""解析接口响应并返回统一结构。
判定规则:
- 若 ``ResponseMetadata.Error`` 不存在,视为成功;
- 若 ``ResponseMetadata.Error.Code`` 为 "0" 或 0,视为成功;
- 否则视为异常。
返回值为 (is_ok, error_code, error_message, result, response_metadata)。
"""
if not isinstance(response, dict):
return (
False,
"InvalidResponse",
"Response is not a JSON object.",
None,
{},
)
metadata_raw = response.get("ResponseMetadata")
result = response.get("Result")
if not isinstance(metadata_raw, dict):
# 缺失或错误的 ResponseMetadata 视为异常
return (
False,
"MissingResponseMetadata",
"ResponseMetadata is missing or invalid.",
result,
metadata_raw if isinstance(metadata_raw, dict) else {},
)
error = metadata_raw.get("Error")
if not error or not isinstance(error, dict):
# 没有 Error 字段,视为成功
return True, "0", "", result, metadata_raw
code = error.get("Code")
message = error.get("Message", "")
# Code 为 "0" 或 0 时视为成功
if code in (None, "", "0", 0):
return True, "0", "", result, metadata_raw
return False, str(code), str(message), result, metadata_raw
def is_response_ok(response: Dict[str, Any]) -> bool:
"""便捷方法:仅判断响应是否成功。"""
is_ok, _, _, _, _ = parse_api_response(response)
return is_ok
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""公共 HTTP Header 构造模块。"""
from typing import Dict, Optional
SERVICE_NAME = "bytebot"
REGION = "cn-north-1"
CONTENT_TYPE = "application/json"
def generate_authorization() -> str:
"""生成 Authorization 字段的占位方法。
实际签名逻辑由业务方后续接入,此处仅返回空字符串以保证脚本可运行。
如需严格控制,可改为抛出 NotImplementedError。
"""
return ""
def build_headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""构造请求所需的公共 Header。
包含:
- ServiceName: bytebot
- Region: cn-north-1
- Content-Type: application/json
- Authorization: 由 generate_authorization() 生成
"""
headers: Dict[str, str] = {
"ServiceName": SERVICE_NAME,
"Region": REGION,
"Content-Type": CONTENT_TYPE,
"Authorization": generate_authorization(),
}
if extra:
headers.update(extra)
return headers
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Dict, Any
import requests
import json
import os
import sys
from .logger_config import get_file_logger, get_console_logger
from .http_headers import build_headers
# 添加scripts目录到Python路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import volc.volc_openai as volc_openai
file_logger = get_file_logger("http_request")
LOCAL_API_BASE_URL = "http://localhost:8000"
LOCAL_DEBUG = False
def run_submit_job(submit_job_request) -> Dict[str, Any]:
file_logger.info("准备调用 SubmitJob 接口")
file_logger.info("RequestBody: %s", json.dumps(submit_job_request.__dict__, ensure_ascii=False))
if LOCAL_DEBUG:
return _run_submit_job_local(submit_job_request)
headers = buildBytebotVoclHeaders()
response_body = volc_openai.request("POST", "SubmitJob", json.dumps(submit_job_request.__dict__), headers)
return response_body
def _run_submit_job_local(submit_job_request) -> Dict[str, Any]:
url = f"{LOCAL_API_BASE_URL}?Action=SubmitJob"
headers = build_headers()
file_logger.info("URL: %s", url)
file_logger.info("Headers: %s", json.dumps(headers, ensure_ascii=False))
response = requests.post(url, headers=headers, json=submit_job_request.__dict__, timeout=10)
response.raise_for_status()
return response.json()
def query_job_status(job_id: str) -> Dict[str, Any]:
file_logger.info("准备调用 QueryJobStatus,JobId=%s", job_id)
file_logger.info("RequestBody: %s", job_id)
if LOCAL_DEBUG:
return _query_job_status_local(job_id)
headers = buildBytebotVoclHeaders()
body = f'{{"JobId":"{job_id}"}}'
response_body = volc_openai.request("POST", "QueryJobStatus", body, headers)
return response_body
def _query_job_status_local(job_id: str) -> Dict[str, Any]:
url = f"{LOCAL_API_BASE_URL}?Action=QueryJobStatus"
headers = build_headers()
payload = {"JobId": job_id}
file_logger.info("URL: %s", url)
file_logger.info("Headers: %s", json.dumps(headers, ensure_ascii=False))
response = requests.post(url, headers=headers, json=payload, timeout=10)
response.raise_for_status()
return response.json()
def query_job_detail(job_id: str,) -> Dict[str, Any]:
file_logger.info("准备调用 QueryJobDetail,JobId=%s", job_id)
file_logger.info("RequestBody: %s", job_id)
if LOCAL_DEBUG:
return _query_job_detail_local(job_id)
headers = buildBytebotVoclHeaders()
body = f'{{"JobId":"{job_id}"}}'
response_body = volc_openai.request("POST", "QueryJobDetail", body, headers)
return response_body
def _query_job_detail_local(
job_id: str,
) -> Dict[str, Any]:
"""调用 QueryJobDetail 接口。"""
url = f"{LOCAL_API_BASE_URL}?Action=QueryJobDetail"
headers = build_headers()
payload = {"JobId": job_id}
file_logger.info("URL: %s", url)
file_logger.info("Headers: %s", json.dumps(headers, ensure_ascii=False))
response = requests.post(url, headers=headers, json=payload, timeout=10)
response.raise_for_status()
return response.json()
def buildBytebotVoclHeaders() -> Dict[str, str]:
headers = build_headers()
# headers['x-use-ppe'] = '1'
# headers['X-TT-ENV'] = 'ppe_volcengine'
ppe_env = os.environ.get("ppe_env")
if ppe_env:
headers['X-VOLC-ENV'] = ppe_env
return headers
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import sys
import os
from typing import Any, Dict, Optional, Tuple, Callable
from .logger_config import get_file_logger
from .api_response import parse_api_response
from . import http_request
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from schemas.service.job_status import JobStatus
from schemas.service.call_result import CallResult
def query_job_status_once(
job_id: str,
file_logger: Any,
) -> Tuple[Optional[int], Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""查询 Job 状态一次。
返回:(status, status_result, error_info)
- status: Job 状态值。
- status_result: QueryJobStatus 接口的 Result 字段内容。
- error_info: 异常时为 {code, message},否则为 None。
"""
response = http_request.query_job_status(job_id)
file_logger.info("QueryJobStatus 响应: %s", json.dumps(response, ensure_ascii=False))
is_ok, error_code, error_message, result, _ = parse_api_response(response)
if not is_ok:
file_logger.error(
"QueryJobStatus 接口异常,Error.Code=%s, Error.Message=%s",
error_code,
error_message,
)
return None, None, {"code": error_code, "message": error_message}
if not isinstance(result, dict) or "Status" not in result:
file_logger.error("QueryJobStatus 返回成功但 Result 中缺少 Status 字段:%s", result)
return None, None, {
"code": "MissingStatus",
"message": "Result 中未包含 Status 字段",
}
status = result.get("Status")
file_logger.info("当前 Job 状态 Status=%s", status)
return status, result, None
def query_job_detail(
job_id: str,
file_logger: Any,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""查询 Job 详情。
返回:(detail_result, error_info)
- detail_result: 正常时为 Result 字段内容(详情数据)。
- error_info: 异常时为 {code, message},否则为 None。
"""
response = http_request.query_job_detail(job_id)
file_logger.info("QueryJobDetail 响应: %s", json.dumps(response, ensure_ascii=False))
is_ok, error_code, error_message, result, _ = parse_api_response(response)
if not is_ok:
file_logger.error(
"QueryJobDetail 接口异常,Error.Code=%s, Error.Message=%s",
error_code,
error_message,
)
return None, {"code": error_code, "message": error_message}
if not isinstance(result, dict):
file_logger.error("QueryJobDetail 返回成功但 Result 非对象类型:%s", result)
return None, {
"code": "InvalidResult",
"message": "Result 不是 JSON 对象",
}
return result, None
def log_execution_start(
file_logger: Any,
program_name: str,
start_time: Any,
extra_info: Optional[str] = None,
) -> None:
"""记录程序开始执行日志。"""
file_logger.info("=" * 60)
file_logger.info("%s 程序开始执行 - %s", program_name, start_time.strftime("%Y-%m-%d %H:%M:%S"))
if extra_info:
file_logger.info(extra_info)
file_logger.info("=" * 60)
def log_execution_end(
file_logger: Any,
console_logger: Any,
program_name: str,
start_time: Any,
end_time: Any,
error_info: Optional[Dict[str, Any]] = None,
loggers_info_func: Optional[Callable] = None,
loggers_error_func: Optional[Callable] = None,
) -> None:
"""记录程序结束执行日志。"""
duration = (end_time - start_time).total_seconds()
file_logger.info("=" * 60)
if error_info is None:
if loggers_info_func:
loggers_info_func([console_logger, file_logger],
"%s 程序执行成功 - %s",
program_name,
end_time.strftime("%Y-%m-%d %H:%M:%S"),
)
else:
if loggers_error_func:
loggers_error_func([console_logger, file_logger],
"%s 程序执行结束(存在异常) - %s",
program_name,
end_time.strftime("%Y-%m-%d %H:%M:%S"),
)
loggers_error_func([console_logger, file_logger], "错误信息: %s", error_info)
file_logger.info("执行耗时: %.2f 秒", duration)
file_logger.info("=" * 60)
def format_job_detail(
job_id: str,
status: Optional[int],
job_detail: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""整理 JobDetail 信息,包括任务状态、接通情况和 summary。
返回:整理后的字典,包含以下字段:
- JobId: 任务ID
- StatusDescription: 状态描述
- CallResult: 接通结果值(仅当状态为 JobFinished 时)
- CallResultDescription: 接通结果描述(仅当状态为 JobFinished 时)
- Summary: 信息汇总(仅当接通且有摘要时)
"""
result: Dict[str, Any] = {
"JobId": job_id,
}
if status is not None:
status_desc = JobStatus.get_job_status_description(status)
result["StatusDescription"] = status_desc
if status == JobStatus.JobFinished and job_detail is not None:
call_result = job_detail.get("CallResult")
if call_result is not None:
result["CallResult"] = call_result
call_result_desc = CallResult.get_call_result_description(call_result)
result["CallResultDescription"] = call_result_desc
if call_result == CallResult.Connected:
summary = job_detail.get("Summary")
if summary is not None:
result["Summary"] = summary
return result
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""公共日志配置模块。
日志目录:位于当前文件父目录的父目录下的 logs 目录,即:
<项目根目录>/logs/
使用 TimedRotatingFileHandler 按天切分日志文件,并同时输出到控制台。
"""
import logging
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from typing import Optional
_LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def _get_logs_dir() -> Path:
"""获取 logs 目录路径并确保其存在。
日志目录相对当前脚本文件的父级目录上一级:
common/logger_config.py 的上一级目录为项目根目录,日志目录为 <项目根目录>/../logs。
"""
base_dir = Path(__file__).resolve().parent.parent.parent
logs_dir = base_dir / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
return logs_dir
def get_file_logger(name: str, *, level: int = logging.INFO) -> logging.Logger:
"""获取带有按天切分文件 handler 的 logger。
日志文件路径为:<项目根目录>/logs/{name}.log。
多次调用同名 logger 时不会重复添加 handlers。
"""
logger = logging.getLogger(name)
# 避免重复添加 handler
if logger.handlers:
return logger
logger.setLevel(level)
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
# 文件输出,按天切分
logs_dir = _get_logs_dir()
log_file = logs_dir / f"{name}.log"
file_handler = TimedRotatingFileHandler(
filename=log_file,
when="midnight",
interval=1,
backupCount=7,
encoding="utf-8",
)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# 防止日志向 root logger 传播导致重复输出
logger.propagate = False
return logger
def get_console_logger(level: int = logging.INFO) -> logging.Logger:
"""获取仅输出到控制台的 logger。"""
logger = logging.getLogger("console")
# 避免重复添加 handler
if logger.handlers:
return logger
logger.setLevel(level)
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
# 控制台输出
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 防止日志向 root logger 传播导致重复输出
logger.propagate = False
return logger
def loggers_info(logs: list[logging.Logger], msg: str, *args, **kwargs) -> None:
"""日志记录器,同时记录到多个 logger。"""
for logger in logs:
logger.info(msg, *args, **kwargs)
def loggers_error(logs: list[logging.Logger], msg: str, *args, **kwargs) -> None:
"""日志记录器,同时记录到多个 logger。"""
for logger in logs:
logger.error(msg, *args, **kwargs)
def loggers_exception(logs: list[logging.Logger], exc: Exception) -> None:
"""日志记录器,同时记录到多个 logger。"""
for logger in logs:
logger.exception(exc)
def flush_loggers() -> None:
"""刷新所有 logger 的 handlers。"""
for handler in logging.root.handlers:
handler.flush()# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
def is_exec_context():
return os.getenv("OPENCLAW_SHELL") == "exec"
def print_openclaw_session_env(logger):
logger.info(f"通道:%s", os.getenv("OPENCLAW_CHANNEL", "unknown"))
logger.info(f"用户ID:%s", os.getenv("OPENCLAW_USER_ID", "unknown"))
logger.info(f"会话ID:%s", os.getenv("OPENCLAW_SESSION_ID", "unknown"))
logger.info(f"环境变量:{os.environ}")
logger.info(f"trigger_type:%s", os.environ.get("OPENCLAW_TRIGGER_TYPE", "unknown"))
logger.info(f"是否为exec执行场景: {is_exec_context()}")# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import subprocess
from typing import Any, Dict, Optional, Tuple
import sys
import os
import shlex
# 添加scripts目录到Python路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common.logger_config import get_file_logger, get_console_logger, loggers_error, loggers_exception
from common.utils import print_openclaw_session_env
from schemas.service.job_status import JobStatus
from schemas.service.call_result import CallResult
file_logger = get_file_logger("notify")
console_logger = get_console_logger()
def execute_local_shell_command(logger, command) -> Tuple[bool, str]:
"""执行本地shell命令并记录相关日志
Args:
logger: 日志记录器
command: 要执行的shell命令
Returns:
Tuple[bool, str]: (执行是否成功, 命令输出或错误信息)
"""
logger.info(f"开始执行本地shell命令: {command}")
try:
# 执行shell命令
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30 # 30秒超时
)
# 记录命令执行结果
if result.returncode == 0:
logger.info(f"命令执行成功,返回码: {result.returncode}")
logger.info(f"命令输出: {result.stdout}")
# 如果输出内容较长,只记录前500个字符
if len(result.stdout) > 500:
logger.info(f"命令输出(截断): {result.stdout[:500]}...")
return True, result.stdout
else:
logger.error(f"命令执行失败,返回码: {result.returncode}")
logger.error(f"错误输出: {result.stderr}")
return False, result.stderr
except subprocess.TimeoutExpired:
logger.error(f"命令执行超时: {command}")
return False, "命令执行超时"
except Exception as e:
logger.error(f"命令执行异常: {str(e)}")
return False, str(e)
# 校验环境变量
def validate_env_variables(logger) -> bool:
"""校验必要的环境变量是否存在
Args:
logger: 日志记录器
Returns:
bool: 如果所有必要变量都存在则返回True,否则返回False
"""
# 获取当前消息通道
current_channel = os.getenv("OPENCLAW_CHANNEL", "unknown")
# 当前请求的用户唯一ID
user_id = os.getenv("OPENCLAW_USER_ID", "unknown")
# 当前请求的会话id
session_id = os.getenv("OPENCLAW_SESSION_ID", "unknown")
print_openclaw_session_env(logger)
if current_channel == "unknown" or current_channel is None or current_channel.strip() == "":
loggers_error([console_logger, logger], "通道未知,无法发送消息, OPENCLAW_CHANNEL={current_channel}有问题,请检查,否则消息会发送失败")
return False
elif current_channel == "webchat":
if session_id == "unknown":
loggers_error([console_logger, logger], "会话id未知,环境变量OPENCLAW_SESSION_ID未获取到值,无法发送消息")
return False
if session_id == "no_need":
loggers_error([console_logger, logger], "当前渠道是webchat,必须获取到会话id,将其赋给环境变量OPENCLAW_SESSION_ID,否则无法发送消息")
return False
if session_id.startswith("agent:"):
loggers_error([console_logger, logger], "会话id格式错误,必须从sessions_list命令中获取当前会话的session_id")
return False
if user_id == "unknown":
loggers_error([console_logger, logger], "用户唯一ID未知,环境变量OPENCLAW_USER_ID未获取到值,无法发送消息")
return False
return True
def send_message(
job_id: str,
status: Optional[int],
detail: Optional[Dict[str, Any]],
error: Optional[Dict[str, Any]],
) -> None:
"""发送消息的占位方法。
由业务方实现具体的消息发送逻辑,例如推送到 IM 或内部通知系统。
当前方法留空,仅保留参数签名。
"""
file_logger.info(
"准备发送消息,JobId=%s, Status=%s, Detail=%s, Error=%s",
job_id, status, detail, error
)
if not validate_env_variables(file_logger):
loggers_error([console_logger, file_logger], "环境变量校验失败,无法发送消息")
sys.exit(1)
# 获取当前消息通道
current_channel = os.getenv("OPENCLAW_CHANNEL", "unknown")
# 当前请求的用户唯一ID
user_id = os.getenv("OPENCLAW_USER_ID", "unknown")
# 当前请求的会话id
session_id = os.getenv("OPENCLAW_SESSION_ID", "unknown")
print_openclaw_session_env(file_logger)
call_result_desc = None
summary = None
if status == JobStatus.JobFinished:
call_result_desc = CallResult.get_call_result_description(detail['CallResult'])
if detail['CallResult'] == CallResult.Connected:
summary = detail['Summary']
info = ""
if call_result_desc is not None:
info += f'接通状态: {call_result_desc}\n'
if summary is not None:
info += f'\n\n\n{summary}\n'
message = ""
if error is not None:
message = f'等待外呼任务(JobId: {job_id})完成的过程出现异常,请手动查询外呼任务执行结果。错误信息: {error}'
else:
message = f'任务ID: {job_id}\n任务状态: {JobStatus.get_job_status_description(status)}\n'
message += info
if current_channel == "unknown" or current_channel is None or current_channel.strip() == "":
loggers_error([console_logger, file_logger], "通道未知,无法发送消息, OPENCLAW_CHANNEL={current_channel}有问题,请检查,否则消息会发送失败")
return
elif current_channel == "webchat":
if session_id == "unknown":
loggers_error([console_logger, file_logger], "会话Key未知,无法发送消息")
return
command_format = "openclaw agent --session-id %s --message %s --deliver"
command = command_format % (shlex.quote(session_id), shlex.quote(message))
execute_local_shell_command(file_logger, command)
else:
if user_id == "unknown":
loggers_error([console_logger, file_logger], "用户ID未知,无法发送消息")
return
command_format = "openclaw message send --channel %s --target user:%s --message %s"
command = command_format % (shlex.quote(current_channel), shlex.quote(user_id), shlex.quote(message))
execute_local_shell_command(file_logger, command)
file_logger.info(f"消息已发送: {message}")
return
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import datetime
import json
import sys
from typing import Any, Dict, Optional
from common.logger_config import get_file_logger, get_console_logger, loggers_info, loggers_error, loggers_exception, flush_loggers
from common import job_utils
file_logger = get_file_logger("query_job")
console_logger = get_console_logger()
def main() -> None:
parser = argparse.ArgumentParser(description="查询 Job 状态和详情")
parser.add_argument(
"--job_id",
"--JobId",
type=str,
required=True,
help="必填,需要查询的 JobId",
)
args = parser.parse_args()
query_job(args.job_id)
def query_job(job_id: str) -> None:
start_time = datetime.datetime.now()
job_utils.log_execution_start(file_logger, "query_job", start_time)
status: Optional[int] = None
status_result: Optional[Dict[str, Any]] = None
job_detail: Optional[Dict[str, Any]] = None
error_info: Optional[Dict[str, Any]] = None
try:
status, status_result, error_info = job_utils.query_job_status_once(job_id, file_logger)
if error_info is not None:
file_logger.error("查询 Job 状态失败: %s", error_info)
loggers_error([console_logger, file_logger],
json.dumps(
{
"ErrorCode": error_info.get("code"),
"ErrorMessage": error_info.get("message"),
},
ensure_ascii=False,
)
)
else:
if status == 4:
file_logger.info("Job 状态为 4(JobFinished),继续查询详情")
job_detail, detail_error = job_utils.query_job_detail(job_id, file_logger)
if detail_error is not None:
file_logger.error("查询 Job 详情失败: %s", detail_error)
error_info = detail_error
loggers_error([console_logger, file_logger],
json.dumps(
{
"ErrorCode": detail_error.get("code"),
"ErrorMessage": detail_error.get("message"),
},
ensure_ascii=False,
)
)
formatted_result = job_utils.format_job_detail(job_id, status, job_detail)
loggers_info([console_logger, file_logger], json.dumps(formatted_result, ensure_ascii=False))
except Exception as exc:
loggers_exception([console_logger, file_logger], exc)
error_info = {"exception": str(exc)}
flush_loggers()
finally:
end_time = datetime.datetime.now()
job_utils.log_execution_end(
file_logger,
console_logger,
"query_job",
start_time,
end_time,
error_info,
loggers_info,
loggers_error
)
flush_loggers()
sys.exit(0 if error_info is None else 1)
if __name__ == "__main__":
main()
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import sys
from common.logger_config import get_file_logger
from common.utils import print_openclaw_session_env
from submit_job import submit
from wait_job_result import wait_job
def main() -> None:
parser = argparse.ArgumentParser(description="提交外呼 Job")
parser.add_argument(
"--request-body",
type=str,
default="",
help="完整的请求体 JSON 字符串,若提供则优先使用并忽略其他参数",
)
args = parser.parse_args()
job_id = submit(args.request_body)
if job_id is None:
logger.error("SubmitJob 调用失败,未返回 JobId")
sys.exit(1)
else:
logger.info("SubmitJob 调用成功,JobId=%s", job_id)
logger.info("wait_job 开始调用,JobId=%s", job_id)
wait_job(job_id)
if __name__ == "__main__":
logger = get_file_logger("run")
print_openclaw_session_env(logger)
logger.info("开始提交外呼任务,run.py")
main()# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
class SubmitJobParams:
"""外呼任务参数实体类"""
def __init__(self, user_phone_number: str, shop_phone_number: str, first_name: str,
date: str = "", time: str = "", people_number: str = "", shop_name: str = "",
new_date: str = "", new_time: str = "", new_people_number: str = ""):
"""
初始化外呼任务参数对象
Args:
user_phone_number: 用户联系电话(所有场景必填)
shop_phone_number: 门店电话(所有场景必填)
first_name: 用户姓氏(所有场景必填)
date: 预约日期(仅预约场景)
time: 预约时间(仅预约场景)
people_number: 预约人数(仅预约场景)
shop_name: 门店名称(所有场景必填)
new_date: 新预约日期(仅改订场景)
new_time: 新预约时间(仅改订场景)
new_people_number: 新预约人数(仅改订场景)
"""
self.user_phone_number = user_phone_number
self.shop_phone_number = shop_phone_number
self.first_name = first_name
self.date = date
self.time = time
self.people_number = people_number
self.new_date = new_date
self.new_time = new_time
self.new_people_number = new_people_number
self.shop_name = shop_name
class SubmitJobOuter:
"""
外呼任务数据模型类
对应JSON结构:
{
"job_name": "【预约/改订/取消】+ 门店名称 + 用户需求",
"job_description": "用户自然语言原始需求(可选)",
"script_id": "对应场景的剧本ID",
"params": {
"user_phone_number": "联系电话(所有场景必填)",
"date": "预约日期(仅预约场景)",
"time": "预约时间(仅预约场景)",
"people_number": "预约人数(仅预约场景)",
"shop_phone_number": "门店电话(所有场景必填)",
"first_name": "用户姓氏(所有场景必填)",
"new_date": "新预约日期(仅改订场景)",
"new_time": "新预约时间(仅改订场景)",
"new_people_number": "新预约人数(仅改订场景)",
"shop_name": "门店名称(所有场景必填)"
}
}
"""
def __init__(self, job_name: str, script_id: str, params: SubmitJobParams,
job_description: str = ""):
"""
初始化外呼任务对象
Args:
job_name: 任务名称,格式为"【预约/改订/取消】+ 门店名称 + 用户需求"
script_id: 剧本ID
params: 参数实体对象
job_description: 用户自然语言原始需求(可选)
"""
self.job_name = job_name
self.job_description = job_description
self.script_id = script_id
self.params = params
def to_dict(self) -> dict:
"""
将对象转换为字典格式
Returns:
dict: 包含所有字段的字典
"""
return {
"job_name": self.job_name,
"job_description": self.job_description,
"script_id": self.script_id,
"params": {
"user_phone_number": self.params.user_phone_number,
"shop_phone_number": self.params.shop_phone_number,
"first_name": self.params.first_name,
"date": self.params.date,
"time": self.params.time,
"people_number": self.params.people_number,
"shop_name": self.params.shop_name,
"new_date": self.params.new_date,
"new_time": self.params.new_time,
"new_people_number": self.params.new_people_number
}
}
@classmethod
def from_json(cls, json_str: str) -> 'SubmitJobOuter':
"""
从JSON字符串创建OutboundJob对象
Args:
json_str: 包含外呼任务数据的JSON字符串
Returns:
SubmitJobOuter: 创建的OutboundJob对象
"""
data = json.loads(json_str)
return cls.from_dict(data)
@classmethod
def from_dict(cls, data: dict) -> 'SubmitJobOuter':
"""
从字典创建OutboundJob对象
Args:
data: 包含外呼任务数据的字典
Returns:
SubmitJobOuter: 创建的OutboundJob对象
"""
params_data = data.get('params', {})
params = SubmitJobParams(
user_phone_number=params_data.get('user_phone_number', ''),
shop_phone_number=params_data.get('shop_phone_number', ''),
first_name=params_data.get('first_name', ''),
date=params_data.get('date', ''),
time=params_data.get('time', ''),
people_number=params_data.get('people_number', ''),
new_date=params_data.get('new_date', ''),
new_time=params_data.get('new_time', ''),
shop_name=params_data.get('shop_name', ''),
new_people_number=params_data.get('new_people_number', '')
)
return cls(
job_name=data.get('job_name', ''),
job_description=data.get('job_description', ''),
script_id=data.get('script_id', ''),
params=params
)
def validate(self) -> tuple[bool, str]:
"""
验证必填字段是否完整
Returns:
tuple[bool, str]: (是否验证通过, 错误信息)
"""
if not self.job_name:
return False, "任务名称不能为空"
if not self.script_id:
return False, "剧本ID不能为空"
if not self.params.user_phone_number:
return False, "用户联系电话不能为空"
if not self.params.shop_phone_number:
return False, "门店电话不能为空"
if not self.params.first_name:
return False, "用户姓氏不能为空"
if not self.params.shop_name:
return False, "门店名称不能为空"
# 根据剧本ID验证场景特定字段
if self.script_id == ScriptType.RESERVATION: # 预约场景
if not self.params.date:
return False, "预约场景必须提供预约日期"
if not self.params.time:
return False, "预约场景必须提供预约时间"
if not self.params.people_number:
return False, "预约场景必须提供预约人数"
elif self.script_id == ScriptType.MODIFICATION: # 改订场景
if not self.params.new_date:
return False, "改订场景必须提供新预约日期"
if not self.params.new_time:
return False, "改订场景必须提供新预约时间"
if not self.params.new_people_number:
return False, "改订场景必须提供新预约人数"
return True, "验证通过"
def __str__(self) -> str:
"""返回对象的字符串表示"""
return f"SubmitJobOuter(job_name='{self.job_name}', script_id='{self.script_id}')"
def __repr__(self) -> str:
"""返回对象的详细字符串表示"""
return (f"SubmitJobOuter(job_name='{self.job_name}', "
f"job_description='{self.job_description}', "
f"script_id='{self.script_id}', "
f"params={self.params})")
# 场景常量定义
class ScriptType:
"""剧本类型常量"""
RESERVATION = "llm_wlne_biffj" # 预约
MODIFICATION = "llm_eeac_bigci" # 改订
CANCELLATION = "llm_ivqy_bigcj" # 取消# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from enum import IntEnum, unique
@unique # 确保枚举值唯一,避免重复定义
class CallResult(IntEnum):
"""
呼叫任务(Job)状态枚举
枚举值对应含义和说明:
1: Waiting - 等待呼叫。Job 已提交,正在排队等待系统发起呼叫。
2: Running - 呼叫中或等待重呼。系统正在进行呼叫,或者当前轮次呼叫失败后,正在等待下一个重呼周期的到来。
3: CallFinished - 呼叫结束。所有预定的呼叫(包括重呼)均已执行完毕,但 Job 的最终数据仍在处理中。
4: JobFinished - Job 结束。Job 已彻底完成,此时可以通过 QueryJobDetail 接口查询到完整的呼叫结果,如通话详情、定级信息等。
5: NoExist - Job不存在
"""
# 定义枚举成员(值: 名称)
Connected = 1
Shutdown = 2
OutOfService = 3
NotFound = 4
ConnectionFailed = 6
NoAnswer = 7
Busy = 8
NotConnected = 2000
@classmethod
def get_call_result_description(cls, value):
"""
根据枚举值获取对应的详细说明
:param value: 枚举数值(1-5)
:return: 对应的说明文本,若不存在返回 None
"""
desc_map = {
cls.Connected: "正常接通",
cls.Shutdown: "关机",
cls.OutOfService: "停机",
cls.NotFound: "空号",
cls.ConnectionFailed: "暂时无法接通",
cls.NoAnswer: "无人接听",
cls.Busy: "被叫忙",
cls.NotConnected: "未接通",
}
return desc_map.get(cls(value), None)
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from enum import IntEnum, unique
@unique # 确保枚举值唯一,避免重复定义
class JobStatus(IntEnum):
"""
呼叫任务(Job)状态枚举
枚举值对应含义和说明:
1: Waiting - 呼叫任务已提交,正在排队等待系统发起呼叫。
2: Running - 呼叫任务正在处理中,如商家未接听,系统会自动重呼,请耐心等待。
3: CallFinished - 所有预定的呼叫(包括重呼)均已执行完毕,正在分析通话结果。
4: JobFinished -已完成预订的呼叫,并且完成通话结果分析。。
5: NoExist - 呼叫任务不存在
"""
# 定义枚举成员(值: 名称)
Waiting = 1
Running = 2
CallFinished = 3
JobFinished = 4
NoExist = 5
@classmethod
def get_job_status_description(cls, value):
"""
根据枚举值获取对应的详细说明
:param value: 枚举数值(1-5)
:return: 对应的说明文本,若不存在返回 None
"""
desc_map = {
cls.Waiting: "Job 已提交,正在排队等待系统发起呼叫。",
cls.Running: "呼叫任务正在处理中,如商家未接听,系统会自动重呼,请耐心等待。",
cls.CallFinished: "所有预定的呼叫(包括重呼)均已执行完毕,正在分析通话结果。",
cls.JobFinished: "已完成预订的呼叫,并且完成通话结果分析。",
cls.NoExist: "呼叫任务不存在"
}
return desc_map.get(cls(value), None)
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
class Error:
"""错误信息类"""
def __init__(self, Code: str, Message: str):
"""
初始化错误信息
Args:
Code: 错误码
Message: 错误描述
"""
self.Code = Code
self.Message = Message
class ResponseMetadata:
"""响应元数据类"""
def __init__(self, RequestId: str, Action: str, Version: str, Service: str, Region: str, Error: Error = None):
"""
初始化响应元数据
Args:
RequestId: 请求ID
Action: 操作名称
Version: API版本
Service: 服务名称
Region: 区域
Error: 错误信息
"""
self.RequestId = RequestId
self.Action = Action
self.Version = Version
self.Service = Service
self.Region = Region
self.Error = Error
@classmethod
def from_dict(cls, param):
"""
从dict创建ResponseMetadata对象
Args:
param: 包含响应元数据的dict
Returns:
ResponseMetadata对象
"""
return cls(
RequestId=param["RequestId"],
Action=param["Action"],
Version=param["Version"],
Service=param["Service"],
Region=param["Region"],
Error=Error(param["Error"]["Code"], param["Error"]["Message"]) if "Error" in param else None
)
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from .response_meta_data import ResponseMetadata
class SubmitJobRequest:
"""提交外呼任务请求类"""
def __init__(self, Phone: str, ScriptName: str, VariableParams: dict):
"""
初始化提交任务请求
Args:
Phone: 电话号码
ScriptName: 剧本名称
VariableParams: 变量参数字典,包含用户自定义参数
"""
self.Phone = Phone
self.ScriptName = ScriptName
self.VariableParams = VariableParams
class SubmitJobResult:
"""提交任务结果类"""
def __init__(self, JobId: str):
"""
初始化提交任务结果
Args:
JobId: 任务ID
"""
self.JobId = JobId
class SubmitJobResponse:
"""提交外呼任务响应类"""
def __init__(self, ResponseMetadata: ResponseMetadata, Result: SubmitJobResult):
"""
初始化提交任务响应
Args:
ResponseMetadata: 响应元数据
Result: 任务结果
"""
self.ResponseMetadata = ResponseMetadata
self.Result = Result
@classmethod
def from_dict(cls, response_body: dict) -> 'SubmitJobResponse':
"""
从dict创建SubmitJobResponse对象
Args:
response_body: JSON格式的响应字符串
Returns:
SubmitJobResponse对象
"""
# 创建ResponseMetadata对象
response_metadata = ResponseMetadata.from_dict(response_body["ResponseMetadata"])
# 创建SubmitJobResult对象
result = SubmitJobResult(response_body["Result"]["JobId"])
# 返回SubmitJobResponse对象
return cls(response_metadata, result)
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import datetime
import sys
from typing import Any, Dict
from common.logger_config import get_file_logger, get_console_logger, loggers_info, loggers_error, loggers_exception, flush_loggers
from common.api_response import parse_api_response
import common.http_request as http_request
from common.utils import print_openclaw_session_env
from schemas.script.submit_job_schema import *
from schemas.service.submit_job_data import *
import message.notify as notify
import subprocess
import time
import os
import json
file_logger = get_file_logger("submit_job")
console_logger = get_console_logger("submit_job_console")
current_dir = os.path.dirname(os.path.abspath(__file__))
def parse_request_body(request_body: str) -> Dict[str, Any]:
"""解析 --request-body 参数中的 JSON 字符串。"""
if not request_body:
return {}
try:
parsed_data: Dict[str, Any] = json.loads(request_body)
if not isinstance(parsed_data, dict):
raise ValueError("request_body 必须是 JSON 对象(顶层为 {})")
return parsed_data
except json.JSONDecodeError as e:
raise ValueError(f"JSON 解析失败: {e}") from e
def build_submit_job_body_from_args(args: argparse.Namespace) -> Dict[str, Any]:
"""根据命令行参数构造 SubmitJob 的请求体。"""
if not args.phone:
raise ValueError("当未提供 --request-body 时,--phone 为必填参数")
body: Dict[str, Any] = {"Phone": args.phone}
if args.script_name:
body["ScriptName"] = args.script_name
if args.params:
try:
variable_params = json.loads(args.params)
except json.JSONDecodeError as e:
raise ValueError(f"--params 解析失败,应为 JSON 字符串: {e}") from e
if not isinstance(variable_params, dict):
raise ValueError("--params 必须为 JSON 对象(键值对)")
body["VariableParams"] = variable_params
return body
def _convert_submit_outer_to_request(submit_job_outer: SubmitJobOuter) -> SubmitJobRequest:
"""将 SubmitJobOuter 对象转换为 SubmitJobRequest 对象。"""
# submit_job_outer中的params 是 SubmitJobParams 类型,需要转换为 dict
variable_params = submit_job_outer.params.__dict__
return SubmitJobRequest(
Phone=variable_params['shop_phone_number'],
ScriptName=submit_job_outer.script_id,
VariableParams=variable_params,
)
def call_submit_job_api(
request_body: Dict[str, Any],
) -> Dict[str, Any]:
"""调用 SubmitJob 接口。"""
# 从请求体创建 SubmitJobOuter 对象
try:
submit_job_outer = SubmitJobOuter.from_json(request_body)
except ValueError as e:
file_logger.error("请求体转换为 OutboundJob 对象失败: %s", e)
raise
# 验证必填字段是否完整
is_valid, error_msg = submit_job_outer.validate()
if not is_valid:
file_logger.error("外呼任务数据验证失败: %s", error_msg)
raise ValueError(error_msg)
# 将 SubmitJobOuter 对象转换为 SubmitJobRequest 对象
submit_job_request = _convert_submit_outer_to_request(submit_job_outer)
response = http_request.run_submit_job(submit_job_request)
return response
def main() -> None:
parser = argparse.ArgumentParser(description="提交外呼 Job")
parser.add_argument(
"--request-body",
type=str,
default="",
help="完整的请求体 JSON 字符串,若提供则优先使用并忽略其他参数",
)
parser.add_argument(
"--phone",
type=str,
help="必填,需呼叫的电话号码(当未提供 --request-body 时)",
)
parser.add_argument(
"--script-name",
type=str,
help="可选,剧本名称 ScriptName",
)
parser.add_argument(
"--params",
type=str,
help="可选,VariableParams,JSON 字符串,形如 '{\"key\": \"value\"}'",
)
args = parser.parse_args()
submit(args.request_body)
def submit(request_body: Dict[str, Any]) -> str:
start_time = datetime.datetime.now()
file_logger.info("=" * 60)
file_logger.info("submit_job 程序开始执行 - %s", start_time.strftime("%Y-%m-%d %H:%M:%S"))
file_logger.info("=" * 60)
try:
file_logger.info("最终 SubmitJob 请求体: %s", json.dumps(request_body, ensure_ascii=False))
# 调用接口
response_body = call_submit_job_api(request_body)
# 记录完整响应
file_logger.info("SubmitJob 接口响应: %s", json.dumps(response_body, ensure_ascii=False))
# 解析统一响应结构
is_ok, error_code, error_message, result, _ = parse_api_response(response_body)
if not is_ok:
loggers_error([file_logger, console_logger],
"SubmitJob 调用异常,Error.Code=%s, Error.Message=%s",
error_code,
error_message,
)
sys.exit(1)
# response_body是一个json字符串,解析成SubmitJobResponse对象
submit_job_response = SubmitJobResponse.from_dict(response_body)
job_id = submit_job_response.Result.JobId
if not job_id:
file_logger.error("SubmitJob 返回成功但未包含 JobId 字段")
loggers_error([file_logger, console_logger],
json.dumps(
{
"ErrorCode": "MissingJobId",
"ErrorMessage": "Result 中未包含 JobId 字段",
},
ensure_ascii=False,
)
)
sys.exit(1)
end_time = datetime.datetime.now()
duration = (end_time - start_time).total_seconds()
file_logger.info("=" * 60)
file_logger.info(
"submit_job 程序执行成功 - %s", end_time.strftime("%Y-%m-%d %H:%M:%S")
)
file_logger.info("执行耗时: %.2f 秒", duration)
file_logger.info("=" * 60)
loggers_info([file_logger, console_logger], "SubmitJob 调用成功,JobId=%s", job_id)
process =subprocess.Popen(
["python3", os.path.join(current_dir, "wait_job_result.py"), "--JobId", job_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
)
time.sleep(1)
status = process.poll()
if status is not None:
loggers_error([file_logger, console_logger], "wait_job_result.py 子进程执行失败,状态码=%s,JobId=%s", status, job_id)
return job_id
except Exception as exc: # pragma: no cover - 兜底异常日志
end_time = datetime.datetime.now()
duration = (end_time - start_time).total_seconds()
loggers_exception([file_logger, console_logger], exc)
file_logger.error("执行耗时: %.2f 秒", duration)
file_logger.error("=" * 60)
flush_loggers()
raise
flush_loggers()
if __name__ == "__main__":
print_openclaw_session_env(file_logger)
# 跳过环境变量校验,直接执行
if not notify.validate_env_variables(file_logger):
file_logger.error("环境变量校验失败,请参考错误输出,检查哪些变量没有传递,重新提交命令")
sys.exit(1)
main()# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import hashlib
import hmac
import json
from urllib.parse import quote
import requests
import os
import sys
# 添加scripts目录到Python路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common.logger_config import get_file_logger
file_logger = get_file_logger('volc_openai')
# 以下参数视服务不同而不同,一个服务内通常是一致的
Service = "bytebot"
Version = "2023-01-01"
Region = "cn-north-1"
Host = "open.volcengineapi.com"
ContentType = "application/json"
def norm_query(params):
query = ""
for key in sorted(params.keys()):
if type(params[key]) == list:
for k in params[key]:
query = (
query + quote(key, safe="-_.~") + "=" + quote(k, safe="-_.~") + "&"
)
else:
query = (query + quote(key, safe="-_.~") + "=" + quote(params[key], safe="-_.~") + "&")
query = query[:-1]
return query.replace("+", "%20")
# 第一步:准备辅助函数。
# sha256 非对称加密
def hmac_sha256(key: bytes, content: str):
return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest()
# sha256 hash算法
def hash_sha256(content: str):
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def request(method, action, body, headers={}):
date = utc_now()
query = {}
ak = os.environ.get("VOLCENGINE_ACCESS_KEY")
if ak is None:
raise ValueError("VOLCENGINE_ACCESS_KEY is not set")
sk = os.environ.get("VOLCENGINE_SECRET_KEY")
if sk is None:
raise ValueError("VOLCENGINE_SECRET_KEY is not set")
return _request(method, date, query, headers, ak, sk, action, body)
# 第二步:签名请求函数
def _request(method, date, query, header, ak, sk, action, body):
# 第三步:创建身份证明。其中的 Service 和 Region 字段是固定的。ak 和 sk 分别代表
# AccessKeyID 和 SecretAccessKey。同时需要初始化签名结构体。一些签名计算时需要的属性也在这里处理。
# 初始化身份证明结构体
credential = {
"access_key_id": ak,
"secret_access_key": sk,
"service": Service,
"region": Region,
}
path = "/"
# 初始化签名结构体
request_param = {
"body": body,
"host": Host,
"path": path,
"method": method,
"content_type": ContentType,
"date": date,
"query": {"Action": action, "Version": Version, **query},
}
if body is None:
request_param["body"] = ""
# 第四步:接下来开始计算签名。在计算签名前,先准备好用于接收签算结果的 signResult 变量,并设置一些参数。
# 初始化签名结果的结构体
x_date = request_param["date"].strftime("%Y%m%dT%H%M%SZ")
short_x_date = x_date[:8]
x_content_sha256 = hash_sha256(request_param["body"])
sign_result = {
"Host": request_param["host"],
"X-Content-Sha256": x_content_sha256,
"X-Date": x_date,
"Content-Type": request_param["content_type"],
}
# 第五步:计算 Signature 签名。
signed_headers_str = ";".join(
["content-type", "host", "x-content-sha256", "x-date"]
)
# signed_headers_str = signed_headers_str + ";x-security-token"
canonical_request_str = "\n".join(
[request_param["method"].upper(),
request_param["path"],
norm_query(request_param["query"]),
"\n".join(
[
"content-type:" + request_param["content_type"],
"host:" + request_param["host"],
"x-content-sha256:" + x_content_sha256,
"x-date:" + x_date,
]
),
"",
signed_headers_str,
x_content_sha256,
]
)
# 打印正规化的请求用于调试比对
file_logger.info("正规化的请求: %s", canonical_request_str)
hashed_canonical_request = hash_sha256(canonical_request_str)
# 打印hash值用于调试比对
file_logger.info("hash值: %s", hashed_canonical_request)
credential_scope = "/".join([short_x_date, credential["region"], credential["service"], "request"])
string_to_sign = "\n".join(["HMAC-SHA256", x_date, credential_scope, hashed_canonical_request])
# 打印最终计算的签名字符串用于调试比对
file_logger.info("最终计算的签名字符串: %s", string_to_sign)
k_date = hmac_sha256(credential["secret_access_key"].encode("utf-8"), short_x_date)
k_region = hmac_sha256(k_date, credential["region"])
k_service = hmac_sha256(k_region, credential["service"])
k_signing = hmac_sha256(k_service, "request")
signature = hmac_sha256(k_signing, string_to_sign).hex()
sign_result["Authorization"] = "HMAC-SHA256 Credential={}, SignedHeaders={}, Signature={}".format(
credential["access_key_id"] + "/" + credential_scope,
signed_headers_str,
signature,
)
header = {**header, **sign_result}
# header = {**header, **{"X-Security-Token": SessionToken}}
# 第六步:将 Signature 签名写入 HTTP Header 中,并发送 HTTP 请求。
request_host = Host
r = requests.request(method=method,
url="https://{}{}".format(request_host, request_param["path"]),
headers=header,
params=request_param["query"],
data=request_param["body"],
)
# print(f"Response status code: {r.status_code}")
# print(f"Response content: {r.text}")
try:
return r.json()
except Exception as e:
print(f"JSON decode error: {e}")
return None
# datetime.utcnow() 在 3.12+ 已经过期,使用如下方法兼容
def utc_now():
try:
from datetime import timezone
return datetime.datetime.now(timezone.utc)
except ImportError:
class UTC(datetime.tzinfo):
def utcoffset(self, dt):
return datetime.timedelta(0)
def tzname(self, dt):
return "UTC"
def dst(self, dt):
return datetime.timedelta(0)
return datetime.datetime.now(UTC())
if __name__ == "__main__":
now = utc_now()
from common.http_request import build_headers
headers = build_headers()
# --header 'ServiceName: bytebot' \
# --header 'Region: cn-north-1' \
# --header 'Content-Type: application/json' \
# headers["ServiceName"] = "bytebot"
# headers["Region"] = "cn-north-1"
# headers["Content-Type"] = "application/json"
# response_body = request("POST", "QueryTaskList", '{"Limit":20,"Offset":0}')
# print(json.dumps(response_body, ensure_ascii=False))
body = '{"ScriptName":"llm_axfn_biffg","Phone":"15201586220","VariableParams":{"user_phone_number":"213213123","first_name":"朱","time":"19点","date":"2016年3月24日","shop_name":"海底捞"}}'
# headers['x-use-ppe'] = '1'
# headers['X-TT-ENV'] = 'ppe_volcengine'
headers['X-VOLC-ENV'] = 'ppe_open_claw'
print(os.environ.get("VOLCENGINE_ACCESS_KEY"))
print(os.environ.get("VOLCENGINE_SECRET_KEY"))
response_body = request("POST", "SubmitJob", body, headers)
print(json.dumps(response_body, ensure_ascii=False))
sys.exit(1)
file_logger.info("=" * 60)
body = '{"JobId":"e705f53f-0bbf-4770-a288-8f2f035ea58a"}'
# headers['x-use-ppe'] = '1'
# headers['X-TT-ENV'] = 'ppe_volcengine'
headers['X-VOLC-ENV'] = 'ppe_open_claw'
response_body = request("POST", "QueryJobStatus", body, headers)
print(json.dumps(response_body, ensure_ascii=False))
file_logger.info("=" * 60)
body = '{"JobId":"e705f53f-0bbf-4770-a288-8f2f035ea58a"}'
headers['x-use-ppe'] = '1'
headers['X-TT-ENV'] = 'ppe_volcengine'
headers['X-VOLC-ENV'] = 'ppe_open_claw'
response_body = request("POST", "QueryJobDetail", body, headers)
print(json.dumps(response_body, ensure_ascii=False))
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import datetime
import json
import sys
import time
from typing import Any, Dict, Optional, Tuple
from common.logger_config import get_file_logger, get_console_logger, loggers_info, loggers_error, flush_loggers
from common.api_response import parse_api_response
from common import http_request
from common import job_utils
from message.notify import send_message, validate_env_variables
file_logger = get_file_logger("wait_job_result")
console_logger = get_console_logger()
def poll_job_status(
job_id: str,
interval_sec: int,
max_polls: int,
) -> Tuple[Optional[int], Optional[Dict[str, Any]]]:
"""轮询 QueryJobStatus,返回最终状态及错误信息。
返回:(final_status, error_info)
- final_status: 最后一次获取到的 Status 值(可能为 None)。
- error_info: 若发生异常或达到退出条件,返回 {code, message},否则为 None。
"""
final_status: Optional[int] = None
error_info: Optional[Dict[str, Any]] = None
for attempt in range(1, max_polls + 1):
file_logger.info("第 %d 次轮询 Job 状态,JobId=%s", attempt, job_id)
status, _, error_info = job_utils.query_job_status_once(job_id, file_logger)
if error_info is not None:
break
final_status = status
if status == -1:
# 业务定义的异常状态
file_logger.error("Job 状态为 -1,视为执行异常,停止轮询。")
error_info = {
"code": "-1",
"message": "Job 执行异常(Status=-1)",
}
break
if status == 4:
# JobFinished,轮询成功结束
file_logger.info("Job 状态为 4(JobFinished),轮询结束。")
break
if status in (1, 2, 3):
# Waiting / Running / CallFinished,继续轮询
if attempt >= max_polls:
file_logger.warning(
"已达到最大轮询次数 %d,Job 仍未结束,停止轮询。", max_polls
)
error_info = {
"code": "MaxPollsExceeded",
"message": f"在最大轮询次数 {max_polls} 内未达到结束状态",
}
break
file_logger.info(
"Job 未结束,%d 秒后进行下一次轮询……", interval_sec
)
time.sleep(interval_sec)
continue
# 未知状态,视为异常
file_logger.error("收到未知 Job 状态: %s,停止轮询。", status)
error_info = {
"code": "UnknownStatus",
"message": f"未知的 Job 状态: {status}",
}
break
return final_status, error_info
def main() -> None:
parser = argparse.ArgumentParser(description="轮询等待 Job 结果并查询详情")
parser.add_argument(
"--job_id",
"--JobId",
type=str,
required=True,
help="必填,需要轮询的 JobId",
)
parser.add_argument(
"--interval-sec",
type=int,
default=30,
help="轮询间隔秒数,默认 30",
)
parser.add_argument(
"--max-polls",
type=int,
default=120,
help="最大轮询次数,默认 120,2个小时",
)
args = parser.parse_args()
# 等待10秒,确保云端数据同步完成
time.sleep(10)
wait_job(args.job_id, args.interval_sec, args.max_polls)
def wait_job(job_id: str, interval_sec: int=30, max_polls: int=120) -> None:
start_time = datetime.datetime.now()
job_utils.log_execution_start(
file_logger,
"wait_job_result",
start_time,
f"轮询配置:interval_sec={interval_sec}, max_polls={max_polls}"
)
final_status: Optional[int] = None
job_detail: Optional[Dict[str, Any]] = None
error_info: Optional[Dict[str, Any]] = None
try:
final_status, error_info = poll_job_status(job_id, interval_sec, max_polls)
if error_info is not None:
file_logger.error("轮询 Job 状态失败或异常结束: %s", error_info)
else:
file_logger.info("轮询 Job 状态结束,最终 Status=%s", final_status)
if error_info is None and final_status == 4:
job_detail, detail_error = job_utils.query_job_detail(job_id, file_logger)
if detail_error is not None:
file_logger.error("查询 Job 详情失败: %s", detail_error)
error_info = detail_error
else:
assert job_detail is not None
pretty_detail = json.dumps(job_detail, ensure_ascii=False, indent=2)
file_logger.info("Job 详情: %s", pretty_detail)
elif error_info is None and final_status != 4:
file_logger.warning(
"轮询结束但 Job 未进入结束状态,最终 Status=%s", final_status
)
error_info = {
"code": "UnexpectedFinalStatus",
"message": f"轮询结束但 Job 最终状态为 {final_status}",
}
except Exception as exc:
loggers_error([console_logger, file_logger], "wait_job_result 程序执行过程中发生异常: %s", exc)
error_info = {"exception": str(exc)}
flush_loggers()
finally:
try:
send_message(job_id, final_status, job_detail, error_info)
except Exception as exc:
loggers_error([console_logger, file_logger], "send_message 调用异常: %s", exc)
end_time = datetime.datetime.now()
job_utils.log_execution_end(
file_logger,
console_logger,
"wait_job_result",
start_time,
end_time,
error_info,
loggers_info,
loggers_error
)
flush_loggers()
sys.exit(0 if error_info is None else 1)
if __name__ == "__main__":
if not validate_env_variables(file_logger):
file_logger.error("环境变量校验失败,程序退出")
sys.exit(1)
main()Related skills
FAQ
What tasks can byted-outbound-call handle?
It handles restaurant reservation, modification, and cancellation calls, and rejects other call types.
What happens if required booking details are missing?
It automatically asks the user in natural language until all required fields for that scenario are filled.