瀏覽代碼

add: 增加公众号「发布能力」接口(#530) (#531)

* add: 增加公众号「发布能力」接口(#530)

* fix: go lint

* fix: 修复响应结构体及解析方式

* add: 增加公众号「草稿箱」接口(#530)

* fix: text

* mod: 增加 发布任务完成 消息事件

Co-authored-by: luoyu <luoyu@medlinker.com>
save95 4 年之前
父節點
當前提交
5c4b28acee

+ 39 - 0
doc/api/officialaccount.md

@@ -132,6 +132,8 @@
 
 #### 群发任务管理
 
+[官方文档](https://developers.weixin.qq.com/doc/offiaccount/Shopping_Guide/task-account/shopping-guide.addGuideMassendJob.html)
+
 | 名称                 | 请求方式 | URL                                   | 是否已实现 | 使用方法 |
 | -------------------- | -------- | ------------------------------------- | ---------- | -------- |
 | 添加群发任务         | POST     | /cgi-bin/guide/addguidemassendjob     | NO         |          |
@@ -156,6 +158,43 @@
 
 ## 素材管理
 
+## 草稿箱
+
+[官方文档](https://developers.weixin.qq.com/doc/offiaccount/Draft_Box/Add_draft.html)
+
+| 名称                       | 请求方式 | URL                                                          | 是否已实现 | 使用方法                     |
+| -------------------------- | -------- | ------------------------------------------------------------ | ---------- | ---------------------------- |
+| 新建草稿                   | POST     | /cgi-bin/draft/add                                           | YES        | (draft *Draft) AddDraft      |
+| 获取草稿                   | POST     | /cgi-bin/draft/get                                           | YES        | (draft *Draft) GetDraft      |
+| 删除草稿                   | POST     | /cgi-bin/draft/delete                                        | YES        | (draft *Draft) DeleteDraft   |
+| 修改草稿                   | POST     | /cgi-bin/draft/update                                        | YES        | (draft *Draft) UpdateDraft   |
+| 获取草稿总数               | GET      | /cgi-bin/draft/count                                         | YES        | (draft *Draft) CountDraft    |
+| 获取草稿列表               | POST     | /cgi-bin/draft/batchget                                      | YES        | (draft *Draft) PaginateDraft |
+| MP端开关(仅内测期间使用) | POST     | /cgi-bin/draft/switch<br />/cgi-bin/draft/switch?checkonly=1 | NO         |                              |
+
+
+
+## 发布能力
+
+[官方文档](https://developers.weixin.qq.com/doc/offiaccount/Publish/Publish.html)
+
+说明:「发表记录」包括群发和发布。
+
+注意:该接口,只能处理 "发布" 相关的信息,无法操作和获取 "群发" 相关内容!![官方回复](https://developers.weixin.qq.com/community/develop/doc/0002a4fb2109d8f7a91d421c556c00)
+
+- 群发:主动推送给粉丝,历史消息可看,被搜一搜收录,可以限定部分的粉丝接收到。
+- 发布:不会主动推给粉丝,历史消息列表看不到,但是是公开给所有人的文章。也不会占用群发的次数。每天可以发布多篇内容。可以用于自动回复、自定义菜单、页面模板和话题中,发布成功时会生成一个永久链接。
+
+| 名称                           | 请求方式 | URL                             | 是否已实现 | 使用方法                                |
+| ------------------------------ | -------- | ------------------------------- | ---------- | --------------------------------------- |
+| 发布接口                       | POST     | /cgi-bin/freepublish/submit     | YES        | (freePublish *FreePublish) Publish      |
+| 发布状态轮询接口               | POST     | /cgi-bin/freepublish/get        | YES        | (freePublish *FreePublish) SelectStatus |
+| 事件推送发布结果               |          |                                 | YES         | EventPublishJobFinish                  |
+| 删除发布                       | POST     | /cgi-bin/freepublish/delete     | YES        | (freePublish *FreePublish) Delete       |
+| 通过 article_id 获取已发布文章 | POST     | /cgi-bin/freepublish/getarticle | YES        | (freePublish *FreePublish) First        |
+| 获取成功发布列表               | POST     | /cgi-bin/freepublish/batchget   | YES        | (freePublish *FreePublish) Paginate     |
+
+
 ## 图文消息留言管理
 
 ## 用户管理

+ 228 - 0
officialaccount/draft/draft.go

@@ -0,0 +1,228 @@
+package draft
+
+import (
+	"fmt"
+
+	"github.com/silenceper/wechat/v2/officialaccount/context"
+	"github.com/silenceper/wechat/v2/util"
+)
+
+const (
+	addURL      = "https://api.weixin.qq.com/cgi-bin/draft/add"      // 新建草稿
+	getURL      = "https://api.weixin.qq.com/cgi-bin/draft/get"      // 获取草稿
+	deleteURL   = "https://api.weixin.qq.com/cgi-bin/draft/delete"   // 删除草稿
+	updateURL   = "https://api.weixin.qq.com/cgi-bin/draft/update"   // 修改草稿
+	countURL    = "https://api.weixin.qq.com/cgi-bin/draft/count"    // 获取草稿总数
+	paginateURL = "https://api.weixin.qq.com/cgi-bin/draft/batchget" // 获取草稿列表
+)
+
+// Draft 草稿箱
+type Draft struct {
+	*context.Context
+}
+
+// NewDraft init
+func NewDraft(ctx *context.Context) *Draft {
+	return &Draft{
+		Context: ctx,
+	}
+}
+
+// Article 草稿
+type Article struct {
+	Title              string `json:"title"`                 // 标题
+	Author             string `json:"author"`                // 作者
+	Digest             string `json:"digest"`                // 图文消息的摘要,仅有单图文消息才有摘要,多图文此处为空。
+	Content            string `json:"content"`               // 图文消息的具体内容,支持HTML标签,必须少于2万字符,小于1M,且去除JS
+	ContentSourceURL   string `json:"content_source_url"`    // 图文消息的原文地址,即点击“阅读原文”后的URL
+	ThumbMediaID       string `json:"thumb_media_id"`        // 图文消息的封面图片素材id(必须是永久MediaID)
+	ShowCoverPic       uint   `json:"show_cover_pic"`        // 是否显示封面,0为false,即不显示,1为true,即显示(默认)
+	NeedOpenComment    uint   `json:"need_open_comment"`     // 是否打开评论,0不打开(默认),1打开
+	OnlyFansCanComment uint   `json:"only_fans_can_comment"` // 是否粉丝才可评论,0所有人可评论(默认),1粉丝才可评论
+}
+
+// AddDraft 新建草稿
+func (draft *Draft) AddDraft(articles []*Article) (mediaID string, err error) {
+	accessToken, err := draft.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		Articles []*Article `json:"articles"`
+	}
+	req.Articles = articles
+
+	uri := fmt.Sprintf("%s?access_token=%s", addURL, accessToken)
+	response, err := util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	var res struct {
+		util.CommonError
+		MediaID string `json:"media_id"`
+	}
+	err = util.DecodeWithError(response, &res, "AddDraft")
+	if err != nil {
+		return
+	}
+	mediaID = res.MediaID
+	return
+}
+
+// GetDraft 获取草稿
+func (draft *Draft) GetDraft(mediaID string) (articles []*Article, err error) {
+	accessToken, err := draft.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		MediaID string `json:"media_id"`
+	}
+	req.MediaID = mediaID
+
+	uri := fmt.Sprintf("%s?access_token=%s", getURL, accessToken)
+	response, err := util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	var res struct {
+		util.CommonError
+		NewsItem []*Article `json:"news_item"`
+	}
+	err = util.DecodeWithError(response, &res, "GetDraft")
+	if err != nil {
+		return
+	}
+
+	articles = res.NewsItem
+	return
+}
+
+// DeleteDraft 删除草稿
+func (draft *Draft) DeleteDraft(mediaID string) (err error) {
+	accessToken, err := draft.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		MediaID string `json:"media_id"`
+	}
+	req.MediaID = mediaID
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", deleteURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	err = util.DecodeWithCommonError(response, "DeleteDraft")
+	return
+}
+
+// UpdateDraft 修改草稿
+// index 要更新的文章在图文消息中的位置(多图文消息时,此字段才有意义),第一篇为0
+func (draft *Draft) UpdateDraft(article *Article, mediaID string, index uint) (err error) {
+	accessToken, err := draft.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		MediaID string   `json:"media_id"`
+		Index   uint     `json:"index"`
+		Article *Article `json:"articles"`
+	}
+	req.MediaID = mediaID
+	req.Index = index
+	req.Article = article
+
+	uri := fmt.Sprintf("%s?access_token=%s", updateURL, accessToken)
+	var response []byte
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	err = util.DecodeWithCommonError(response, "UpdateDraft")
+	return
+}
+
+// CountDraft 获取草稿总数
+func (draft *Draft) CountDraft() (total uint, err error) {
+	accessToken, err := draft.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", countURL, accessToken)
+	response, err = util.HTTPGet(uri)
+	if err != nil {
+		return
+	}
+
+	var res struct {
+		util.CommonError
+		Total uint `json:"total_count"`
+	}
+	err = util.DecodeWithError(response, &res, "CountDraft")
+	if nil != err {
+		return
+	}
+
+	total = res.Total
+	return
+}
+
+// ArticleList 草稿列表
+type ArticleList struct {
+	util.CommonError
+	TotalCount int64             `json:"total_count"` // 草稿素材的总数
+	ItemCount  int64             `json:"item_count"`  // 本次调用获取的素材的数量
+	Item       []ArticleListItem `json:"item"`
+}
+
+// ArticleListItem 用于 ArticleList 的 item 节点
+type ArticleListItem struct {
+	MediaID    string             `json:"media_id"`    // 图文消息的id
+	Content    ArticleListContent `json:"content"`     // 内容
+	UpdateTime int64              `json:"update_time"` // 这篇图文消息素材的最后更新时间
+}
+
+// ArticleListContent 用于 ArticleListItem 的 content 节点
+type ArticleListContent struct {
+	NewsItem []Article `json:"news_item"` // 这篇图文消息素材的内容
+}
+
+// PaginateDraft 获取草稿列表
+func (draft *Draft) PaginateDraft(offset, count int64, noReturnContent bool) (list ArticleList, err error) {
+	accessToken, err := draft.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		Count           int64 `json:"count"`
+		Offset          int64 `json:"offset"`
+		NoReturnContent bool  `json:"no_content"`
+	}
+	req.Count = count
+	req.Offset = offset
+	req.NoReturnContent = noReturnContent
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", paginateURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	err = util.DecodeWithError(response, &list, "PaginateDraft")
+	return
+}

+ 248 - 0
officialaccount/freepublish/freepublish.go

@@ -0,0 +1,248 @@
+package freepublish
+
+import (
+	"fmt"
+
+	"github.com/silenceper/wechat/v2/officialaccount/context"
+	"github.com/silenceper/wechat/v2/util"
+)
+
+const (
+	publishURL      = "https://api.weixin.qq.com/cgi-bin/freepublish/submit"     // 发布接口
+	selectStateURL  = "https://api.weixin.qq.com/cgi-bin/freepublish/get"        // 发布状态轮询接口
+	deleteURL       = "https://api.weixin.qq.com/cgi-bin/freepublish/delete"     // 删除发布
+	firstArticleURL = "https://api.weixin.qq.com/cgi-bin/freepublish/getarticle" // 通过 article_id 获取已发布文章
+	paginateURL     = "https://api.weixin.qq.com/cgi-bin/freepublish/batchget"   // 获取成功发布列表
+)
+
+// PublishStatus 发布状态
+type PublishStatus uint
+
+const (
+	// PublishStatusSuccess 0:成功
+	PublishStatusSuccess PublishStatus = iota
+	// PublishStatusPublishing 1:发布中
+	PublishStatusPublishing
+	// PublishStatusOriginalFail 2:原创失败
+	PublishStatusOriginalFail
+	// PublishStatusFail 3:常规失败
+	PublishStatusFail
+	// PublishStatusAuditRefused 4:平台审核不通过
+	PublishStatusAuditRefused
+	// PublishStatusUserDeleted 5:成功后用户删除所有文章
+	PublishStatusUserDeleted
+	// PublishStatusSystemBanned 6:成功后系统封禁所有文章
+	PublishStatusSystemBanned
+)
+
+// FreePublish 发布能力
+type FreePublish struct {
+	*context.Context
+}
+
+// NewFreePublish init
+func NewFreePublish(ctx *context.Context) *FreePublish {
+	return &FreePublish{
+		Context: ctx,
+	}
+}
+
+// Publish 发布接口。需要先将图文素材以草稿的形式保存(见“草稿箱/新建草稿”,
+// 如需从已保存的草稿中选择,见“草稿箱/获取草稿列表”),选择要发布的草稿 media_id 进行发布
+func (freePublish *FreePublish) Publish(mediaID string) (publishID int64, err error) {
+	var accessToken string
+	accessToken, err = freePublish.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		MediaID string `json:"media_id"`
+	}
+	req.MediaID = mediaID
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", publishURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	var res struct {
+		util.CommonError
+		PublishID int64 `json:"publish_id"`
+	}
+	err = util.DecodeWithError(response, &res, "SubmitFreePublish")
+	if err != nil {
+		return
+	}
+
+	publishID = res.PublishID
+	return
+}
+
+// PublishStatusList 发布任务状态列表
+type PublishStatusList struct {
+	util.CommonError
+	PublishID     int64                `json:"publish_id"`     // 发布任务id
+	PublishStatus PublishStatus        `json:"publish_status"` // 发布状态
+	ArticleID     string               `json:"article_id"`     // 当发布状态为0时(即成功)时,返回图文的 article_id,可用于“客服消息”场景
+	ArticleDetail PublishArticleDetail `json:"article_detail"` // 发布任务文章成功状态详情
+	FailIndex     []uint               `json:"fail_idx"`       // 当发布状态为2或4时,返回不通过的文章编号,第一篇为 1;其他发布状态则为空
+}
+
+// PublishArticleDetail 发布任务成功详情
+type PublishArticleDetail struct {
+	Count uint                 `json:"count"` // 当发布状态为0时(即成功)时,返回文章数量
+	Items []PublishArticleItem `json:"item"`
+}
+
+// PublishArticleItem 发布任务成功的文章内容
+type PublishArticleItem struct {
+	Index      uint   `json:"idx"`         // 当发布状态为0时(即成功)时,返回文章对应的编号
+	ArticleURL string `json:"article_url"` // 当发布状态为0时(即成功)时,返回图文的永久链接
+}
+
+// SelectStatus 发布状态轮询接口
+func (freePublish *FreePublish) SelectStatus(publishID int64) (list PublishStatusList, err error) {
+	accessToken, err := freePublish.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		PublishID int64 `json:"publish_id"`
+	}
+	req.PublishID = publishID
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", selectStateURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	err = util.DecodeWithError(response, &list, "SelectStatusFreePublish")
+	return
+}
+
+// Delete 删除发布。
+// index 要删除的文章在图文消息中的位置,第一篇编号为1,该字段不填或填0会删除全部文章
+// !!!此操作不可逆,请谨慎操作!!!删除后微信公众号后台仍然会有记录!!!
+func (freePublish *FreePublish) Delete(articleID string, index uint) (err error) {
+	accessToken, err := freePublish.GetAccessToken()
+	if err != nil {
+		return err
+	}
+
+	var req struct {
+		ArticleID string `json:"article_id"`
+		Index     uint   `json:"index"`
+	}
+	req.ArticleID = articleID
+	req.Index = index
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", deleteURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return err
+	}
+
+	return util.DecodeWithCommonError(response, "DeleteFreePublish")
+}
+
+// Article 图文信息内容
+type Article struct {
+	Title              string `json:"title"`                 // 标题
+	Author             string `json:"author"`                // 作者
+	Digest             string `json:"digest"`                // 图文消息的摘要,仅有单图文消息才有摘要,多图文此处为空
+	Content            string `json:"content"`               // 图文消息的具体内容,支持HTML标签,必须少于2万字符,小于1M,且此处会去除JS
+	ContentSourceURL   string `json:"content_source_url"`    // 图文消息的原文地址,即点击“阅读原文”后的URL
+	ThumbMediaID       string `json:"thumb_media_id"`        // 图文消息的封面图片素材id(一定是永久MediaID)
+	ShowCoverPic       uint   `json:"show_cover_pic"`        // 是否显示封面,0为false,即不显示,1为true,即显示(默认)
+	NeedOpenComment    uint   `json:"need_open_comment"`     // 是否打开评论,0不打开(默认),1打开
+	OnlyFansCanComment uint   `json:"only_fans_can_comment"` // 是否粉丝才可评论,0所有人可评论(默认),1粉丝才可评论
+	URL                string `json:"url"`                   // 图文消息的URL
+	IsDeleted          bool   `json:"is_deleted"`            // 该图文是否被删除
+}
+
+// First 通过 article_id 获取已发布文章
+func (freePublish *FreePublish) First(articleID string) (list []Article, err error) {
+	accessToken, err := freePublish.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		ArticleID string `json:"article_id"`
+	}
+	req.ArticleID = articleID
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", firstArticleURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	var res struct {
+		util.CommonError
+		NewsItem []Article `json:"news_item"`
+	}
+	err = util.DecodeWithError(response, &res, "FirstFreePublish")
+	if err != nil {
+		return
+	}
+
+	list = res.NewsItem
+	return
+}
+
+// ArticleList 发布列表
+type ArticleList struct {
+	util.CommonError
+	TotalCount int64             `json:"total_count"` // 成功发布素材的总数
+	ItemCount  int64             `json:"item_count"`  // 本次调用获取的素材的数量
+	Item       []ArticleListItem `json:"item"`
+}
+
+// ArticleListItem 用于 ArticleList 的 item 节点
+type ArticleListItem struct {
+	ArticleID  string             `json:"article_id"`  // 成功发布的图文消息id
+	Content    ArticleListContent `json:"content"`     // 内容
+	UpdateTime int64              `json:"update_time"` // 这篇图文消息素材的最后更新时间
+}
+
+// ArticleListContent 用于 ArticleListItem 的 content 节点
+type ArticleListContent struct {
+	NewsItem []Article `json:"news_item"` // 这篇图文消息素材的内容
+}
+
+// Paginate 获取成功发布列表
+func (freePublish *FreePublish) Paginate(offset, count int64, noReturnContent bool) (list ArticleList, err error) {
+	var accessToken string
+	accessToken, err = freePublish.GetAccessToken()
+	if err != nil {
+		return
+	}
+
+	var req struct {
+		Count           int64 `json:"count"`
+		Offset          int64 `json:"offset"`
+		NoReturnContent bool  `json:"no_content"`
+	}
+	req.Count = count
+	req.Offset = offset
+	req.NoReturnContent = noReturnContent
+
+	var response []byte
+	uri := fmt.Sprintf("%s?access_token=%s", paginateURL, accessToken)
+	response, err = util.PostJSON(uri, req)
+	if err != nil {
+		return
+	}
+
+	err = util.DecodeWithError(response, &list, "PaginateFreePublish")
+	return
+}

+ 18 - 0
officialaccount/message/message.go

@@ -4,6 +4,7 @@ import (
 	"encoding/xml"
 
 	"github.com/silenceper/wechat/v2/officialaccount/device"
+	"github.com/silenceper/wechat/v2/officialaccount/freepublish"
 )
 
 // MsgType 基本消息类型
@@ -75,6 +76,8 @@ const (
 	EventWxaMediaCheck EventType = "wxa_media_check"
 	// EventSubscribeMsgPopupEvent 订阅通知事件推送
 	EventSubscribeMsgPopupEvent EventType = "subscribe_msg_popup_event"
+	// EventPublishJobFinish 发布任务完成
+	EventPublishJobFinish EventType = "PUBLISHJOBFINISH"
 )
 
 const (
@@ -150,6 +153,21 @@ type MixMessage struct {
 		List SubscribeMsgPopupEvent `xml:"List"`
 	} `xml:"SubscribeMsgPopupEvent"`
 
+	// 事件相关:发布能力
+	PublishEventInfo struct {
+		PublishID     int64                     `xml:"publish_id"`     // 发布任务id
+		PublishStatus freepublish.PublishStatus `xml:"publish_status"` // 发布状态
+		ArticleID     string                    `xml:"article_id"`     // 当发布状态为0时(即成功)时,返回图文的 article_id,可用于“客服消息”场景
+		ArticleDetail struct {
+			Count uint `xml:"count"` // 文章数量
+			Item  []struct {
+				Index      uint   `xml:"idx"`         // 文章对应的编号
+				ArticleURL string `xml:"article_url"` // 图文的永久链接
+			} `xml:"item"`
+		} `xml:"article_detail"` // 当发布状态为0时(即成功)时,返回内容
+		FailIndex []uint `xml:"fail_idx"` // 当发布状态为2或4时,返回不通过的文章编号,第一篇为 1;其他发布状态则为空
+	} `xml:"PublishEventInfo"`
+
 	// 第三方平台相关
 	InfoType                     InfoType `xml:"InfoType"`
 	AppID                        string   `xml:"AppId"`

+ 12 - 0
officialaccount/officialaccount.go

@@ -3,6 +3,8 @@ package officialaccount
 import (
 	"net/http"
 
+	"github.com/silenceper/wechat/v2/officialaccount/draft"
+	"github.com/silenceper/wechat/v2/officialaccount/freepublish"
 	"github.com/silenceper/wechat/v2/officialaccount/ocr"
 
 	"github.com/silenceper/wechat/v2/officialaccount/datacube"
@@ -80,6 +82,16 @@ func (officialAccount *OfficialAccount) GetMaterial() *material.Material {
 	return material.NewMaterial(officialAccount.ctx)
 }
 
+// GetDraft 草稿箱
+func (officialAccount *OfficialAccount) GetDraft() *draft.Draft {
+	return draft.NewDraft(officialAccount.ctx)
+}
+
+// GetFreePublish 发布能力
+func (officialAccount *OfficialAccount) GetFreePublish() *freepublish.FreePublish {
+	return freepublish.NewFreePublish(officialAccount.ctx)
+}
+
 // GetJs js-sdk配置
 func (officialAccount *OfficialAccount) GetJs() *js.Js {
 	return js.NewJs(officialAccount.ctx)