diff --git a/doc/api/officialaccount.md b/doc/api/officialaccount.md index b1d2543..4631c61 100644 --- a/doc/api/officialaccount.md +++ b/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
/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 | + + ## 图文消息留言管理 ## 用户管理 diff --git a/officialaccount/draft/draft.go b/officialaccount/draft/draft.go new file mode 100644 index 0000000..e7f15d3 --- /dev/null +++ b/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 +} diff --git a/officialaccount/freepublish/freepublish.go b/officialaccount/freepublish/freepublish.go new file mode 100644 index 0000000..041bf66 --- /dev/null +++ b/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 +} diff --git a/officialaccount/message/message.go b/officialaccount/message/message.go index 9b28639..841f22f 100644 --- a/officialaccount/message/message.go +++ b/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"` diff --git a/officialaccount/officialaccount.go b/officialaccount/officialaccount.go index 5697312..3aecce8 100644 --- a/officialaccount/officialaccount.go +++ b/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)