Repository: sivchari/gotwtr
Pull request #171 (2023-06-04 13:29:13)

fix: filtered stream silence stop due to io eof
I used filtered stream for about a few weeks, and found that I often suddenly did not receive tweets, and after troubleshooting, I found that the loop quit after encountering the error of io eof, but did not notify the external caller through err chan (the external caller can only use err chan to determine whether it needs to reconnect)


Patch:
diff --git a/README.md b/README.md
index f54a311..b4c7dae 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,6 @@
 # gotwtr
 
 [![Go Reference](https://pkg.go.dev/badge/github.com/sivchari/gotwtr.svg)](https://pkg.go.dev/github.com/sivchari/gotwtr)
-[![codecov](https://codecov.io/gh/sivchari/gotwtr/branch/main/graph/badge.svg?token=Q389TO8ME6)](https://codecov.io/gh/sivchari/gotwtr)
 [![reviewdog](https://github.com/sivchari/gotwtr/actions/workflows/lint.yml/badge.svg)](https://github.com/sivchari/gotwtr/actions/workflows/lint.yml)
 [![test](https://github.com/sivchari/gotwtr/actions/workflows/test.yml/badge.svg)](https://github.com/sivchari/gotwtr/actions/workflows/test.yml)
 
@@ -13,32 +12,6 @@ We covers only Twitter v2 API supported by OAuth 2.0 Bearer Token.
 
 We will had worked on it, when new one is be handled OAuth 2.0.
 
-## Features
-
-### Tweet
- - Tweet lookup
- - Timelines
- - Search Tweets
- - Tweet Counts
- - Filtered Stream
- - Volume Streams
- - Retweets
- - Likes
-
-### Lists
- - List lookup
- - List Tweets lookup
- - List members
- - List follows
-
-### Users
- - User lookup
- - Follows
-
-### Spaces
- - Space lookup
- - Search spaces
-
 ## Installation
 
 ```console
diff --git a/client.go b/client.go
index 35e95ab..98e51d7 100644
--- a/client.go
+++ b/client.go
@@ -127,6 +127,13 @@ type Compliances interface {
 	CreateComplianceJob(ctx context.Context, opt ...*CreateComplianceJobOption) (*CreateComplianceJobResponse, error)
 }
 
+type DirectMessages interface {
+	// Direct Message
+	CreateOneToOneDM(ctx context.Context, participantID string, body *CreateOneToOneDMBody) (*CreateOneToOneDMResponse, error)
+	CreateNewGroupDM(ctx context.Context, conversationID string, body *CreateNewGroupDMBody) (*CreateNewGroupDMResponse, error)
+	PostDM(ctx context.Context, body *PostDMBody) (*PostDMResponse, error)
+}
+
 // Twtr is a main interface for all Twitter API calls.
 type Twtr interface {
 	OAuth
@@ -135,6 +142,7 @@ type Twtr interface {
 	Spaces
 	Lists
 	Compliances
+	DirectMessages
 }
 
 type client struct {
@@ -528,3 +536,18 @@ func (c *Client) CreateComplianceJob(ctx context.Context, opt ...*CreateComplian
 func (c *Client) UpdateMetaDataForList(ctx context.Context, listID string, body ...*UpdateMetaDataForListBody) (*UpdateMetaDataForListResponse, error) {
 	return updateMetaDataForList(ctx, c.client, listID, body...)
 }
+
+// CreateOneToOneDM enables create a new one-to-one conversation or retrieves the current conversation and adds the Direct Message to it.
+func (c *Client) CreateOneToOneDM(ctx context.Context, participantID string, body *CreateOneToOneDMBody) (*CreateOneToOneDMResponse, error) {
+	return createOneToOneDM(ctx, c.client, participantID, body)
+}
+
+// CreateNewGroupDM enables create a Direct Message on behalf of an authenticated user, and adds it to the specified conversation.
+func (c *Client) CreateNewGroupDM(ctx context.Context, conversationID string, body *CreateNewGroupDMBody) (*CreateNewGroupDMResponse, error) {
+	return createNewGroupDM(ctx, c.client, conversationID, body)
+}
+
+// PostDM enables create a new group conversation and adds a Direct Message to it on behalf of an authenticated user.
+func (c *Client) PostDM(ctx context.Context, body *PostDMBody) (*PostDMResponse, error) {
+	return postDM(ctx, c.client, body)
+}
diff --git a/direct_message.go b/direct_message.go
new file mode 100644
index 0000000..145a167
--- /dev/null
+++ b/direct_message.go
@@ -0,0 +1,53 @@
+package gotwtr
+
+type Message struct {
+	Attachments []MessageAttachment `json:"attachments,omitempty"`
+	Text        string              `json:"text,omitempty"`
+}
+
+type MessageAttachment struct {
+	MediaID string `json:"media_id"`
+}
+
+type CreateOneToOneDMBody struct {
+	Text        string              `json:"text,omitempty"`
+	Attachments []MessageAttachment `json:"attachments,omitempty"`
+}
+
+type CreateOneToOneDMResponse struct {
+	DMConversationID string              `json:"dm_conversation_id,omitempty"`
+	DMEventID        string              `json:"dm_event_id,omitempty"`
+	Errors           []*APIResponseError `json:"errors,omitempty"`
+	Title            string              `json:"title,omitempty"`
+	Detail           string              `json:"detail,omitempty"`
+	Type             string              `json:"type,omitempty"`
+}
+
+type CreateNewGroupDMBody struct {
+	Text        string              `json:"text,omitempty"`
+	Attachments []MessageAttachment `json:"attachments,omitempty"`
+}
+
+type CreateNewGroupDMResponse struct {
+	DMConversationID string              `json:"dm_conversation_id,omitempty"`
+	DMEventID        string              `json:"dm_event_id,omitempty"`
+	Errors           []*APIResponseError `json:"errors,omitempty"`
+	Title            string              `json:"title,omitempty"`
+	Detail           string              `json:"detail,omitempty"`
+	Type             string              `json:"type,omitempty"`
+}
+
+type PostDMBody struct {
+	ConversationType string   `json:"conversation_type"`
+	Message          *Message `json:"message"`
+	ParticipantIDs   []string `json:"participant_id"`
+}
+
+type PostDMResponse struct {
+	DMConversationID string              `json:"dm_conversation_id,omitempty"`
+	DMEventID        string              `json:"dm_event_id,omitempty"`
+	Errors           []*APIResponseError `json:"errors,omitempty"`
+	Title            string              `json:"title,omitempty"`
+	Detail           string              `json:"detail,omitempty"`
+	Type             string              `json:"type,omitempty"`
+}
diff --git a/endpoint.go b/endpoint.go
index b71bba5..4fb1b64 100644
--- a/endpoint.go
+++ b/endpoint.go
@@ -7,100 +7,98 @@ const (
 )
 
 const (
-	// Bookmarks
-	removeBookmarkOfTweetURL = "https://api.twitter.com/2/users/%v/bookmarks/%v"
-	lookupUserBookmarksURL   = "https://api.twitter.com/2/users/%v/bookmarks"
-	bookmarkTweetURL         = "https://api.twitter.com/2/users/%v/bookmarks"
-	// Filtered stream
-	connectToStreamURL     = "https://api.twitter.com/2/tweets/search/stream"
-	retrieveStreamRulesURL = "https://api.twitter.com/2/tweets/search/stream/rules"
-	addOrDeleteRulesURL    = "https://api.twitter.com/2/tweets/search/stream/rules"
-	// Hide replies
-	hideRepliesURL = "https://api.twitter.com/2/tweets/%v/hidden"
-	// Likes
-	undoUsersLikingTweetURL = "https://api.twitter.com/2/users/%v/likes/%v"
-	usersLikingTweetURL     = "https://api.twitter.com/2/tweets/%v/liking_users"
-	tweetsUserLikedURL      = "https://api.twitter.com/2/users/%v/liked_tweets"
-	postUsersLikingTweetURL = "https://api.twitter.com/2/users/%v/likes"
+	// Tweets lookup
+	retrieveMultipleTweetsURL = "https://api.twitter.com/2/tweets?ids="
+	retrieveSingleTweetURL    = "https://api.twitter.com/2/tweets/%v"
 	// Manage Tweets
 	deleteTweetURL = "https://api.twitter.com/2/tweets/%v"
 	postTweetURL   = "https://api.twitter.com/2/tweets"
-	// TODO: /2/tweets/:id/quote_tweets
-	// Retweets
-	undoRetweetURL    = "https://api.twitter.com/2/users/%v/retweets/%v"
-	retweetsLookupURL = "https://api.twitter.com/2/tweets/%v/retweeted_by"
-	postRetweetURL    = "https://api.twitter.com/2/users/%v/retweets"
-	// Search Tweets
-	searchAllTweetsURL    = "https://api.twitter.com/2/tweets/search/all"
-	searchRecentTweetsURL = "https://api.twitter.com/2/tweets/search/recent"
 	// Timelines
 	userMentionTimelineURL = "https://api.twitter.com/2/users/%v/mentions"
+	userTweetTimelineURL   = "https://api.twitter.com/2/users/%v/tweets"
 	// TODO: /2/users/:id/timelines/reverse_chronological
-	userTweetTimelineURL = "https://api.twitter.com/2/users/%v/tweets"
+	// Search Tweets
+	searchAllTweetsURL    = "https://api.twitter.com/2/tweets/search/all"
+	searchRecentTweetsURL = "https://api.twitter.com/2/tweets/search/recent"
 	// Tweet counts
 	countsAllTweetsURL    = "https://api.twitter.com/2/tweets/counts/all"
 	countsRecentTweetsURL = "https://api.twitter.com/2/tweets/counts/recent"
-	// Tweets lookup
-	retrieveMultipleTweetsURL = "https://api.twitter.com/2/tweets?ids="
-	retrieveSingleTweetURL    = "https://api.twitter.com/2/tweets/%v"
+	// Filtered stream
+	connectToStreamURL     = "https://api.twitter.com/2/tweets/search/stream"
+	retrieveStreamRulesURL = "https://api.twitter.com/2/tweets/search/stream/rules"
+	addOrDeleteRulesURL    = "https://api.twitter.com/2/tweets/search/stream/rules"
 	// Volume streams
 	volumeStreamsURL = "https://api.twitter.com/2/tweets/sample/stream"
-	// TODO: /2/tweets/sample10/stream
+	// Retweets
+	undoRetweetURL    = "https://api.twitter.com/2/users/%v/retweets/%v"
+	retweetsLookupURL = "https://api.twitter.com/2/tweets/%v/retweeted_by"
+	postRetweetURL    = "https://api.twitter.com/2/users/%v/retweets"
+	// TODO: /2/tweets/:id/quote_tweets
+	// Likes
+	usersLikingTweetURL     = "https://api.twitter.com/2/tweets/%v/liking_users"
+	tweetsUserLikedURL      = "https://api.twitter.com/2/users/%v/liked_tweets"
+	postUsersLikingTweetURL = "https://api.twitter.com/2/users/%v/likes"
+	undoUsersLikingTweetURL = "https://api.twitter.com/2/users/%v/likes/%v"
+	// Bookmarks
+	removeBookmarkOfTweetURL = "https://api.twitter.com/2/users/%v/bookmarks/%v"
+	lookupUserBookmarksURL   = "https://api.twitter.com/2/users/%v/bookmarks"
+	bookmarkTweetURL         = "https://api.twitter.com/2/users/%v/bookmarks"
+	// Hide replies
+	hideRepliesURL = "https://api.twitter.com/2/tweets/%v/hidden"
 )
 
 const (
-	// Blocks
-	undoBlockingURL = "https://api.twitter.com/2/users/%v/blocking/%v"
-	blockingURL     = "https://api.twitter.com/2/users/%v/blocking"
-	postBlockingURL = "https://api.twitter.com/2/users/%v/blocking"
+	// Users lookup
+	retrieveMultipleUsersWithIDsURL       = "https://api.twitter.com/2/users?ids="
+	retrieveSingleUserWithIDURL           = "https://api.twitter.com/2/users/%v"
+	retrieveMultipleUsersWithUserNamesURL = "https://api.twitter.com/2/users/by?usernames="
+	retrieveSingleUserWithUserNameURL     = "https://api.twitter.com/2/users/by/username/%v"
+	meURL                                 = "https://api.twitter.com/2/users/me"
 	// Follows
 	undoFollowingURL = "https://api.twitter.com/2/users/%v/following/%v"
 	followersURL     = "https://api.twitter.com/2/users/%v/followers"
 	followingURL     = "https://api.twitter.com/2/users/%v/following"
 	postFollowingURL = "https://api.twitter.com/2/users/%v/following"
+	// Blocks
+	blockingURL     = "https://api.twitter.com/2/users/%v/blocking"
+	postBlockingURL = "https://api.twitter.com/2/users/%v/blocking"
+	undoBlockingURL = "https://api.twitter.com/2/users/%v/blocking/%v"
 	// Mutes
-	undoMutingURL = "https://api.twitter.com/2/users/%v/muting/%v"
 	mutingURL     = "https://api.twitter.com/2/users/%v/muting"
 	postMutingURL = "https://api.twitter.com/2/users/%v/muting"
-	// Users lookup
-	retrieveMultipleUsersWithIDsURL       = "https://api.twitter.com/2/users?ids="
-	retrieveSingleUserWithIDURL           = "https://api.twitter.com/2/users/%v"
-	retrieveMultipleUsersWithUserNamesURL = "https://api.twitter.com/2/users/by?usernames="
-	retrieveSingleUserWithUserNameURL     = "https://api.twitter.com/2/users/by/username/%v"
-	meURL                                 = "https://api.twitter.com/2/users/me"
+	undoMutingURL = "https://api.twitter.com/2/users/%v/muting/%v"
 )
 
 const (
-	// Search Spaces
-	searchSpacesURL = "https://api.twitter.com/2/spaces/search"
 	// Spaces lookup
-	spacesURL                    = "https://api.twitter.com/2/spaces?ids="
 	spaceURL                     = "https://api.twitter.com/2/spaces/%v"
+	spacesURL                    = "https://api.twitter.com/2/spaces?ids="
 	usersPurchasedSpaceTicketURL = "https://api.twitter.com/2/spaces/%v/buyers"
-	// TODO: /2/spaces/:id/tweets
-	discoverSpacesURL = "https://api.twitter.com/2/spaces/by/creator_ids?user_ids="
+	discoverSpacesURL            = "https://api.twitter.com/2/spaces/by/creator_ids?user_ids="
+	// Search Spaces
+	searchSpacesURL = "https://api.twitter.com/2/spaces/search"
 )
 
 const (
-	// List Tweets lookup
-	lookUpListTweetsURL = "https://api.twitter.com/2/lists/%v/tweets"
-	// List follows
-	undoListFollowsURL     = "https://api.twitter.com/2/users/%v/followed_lists/%v"
-	listFollowersURL       = "https://api.twitter.com/2/lists/%v/followers"
-	allListsUserFollowsURL = "https://api.twitter.com/2/users/%v/followed_lists"
-	postListFollowsURL     = "https://api.twitter.com/2/users/%v/followed_lists"
 	// List lookup
 	lookUpListURL          = "https://api.twitter.com/2/lists/%v"
 	lookUpAllListsOwnedURL = "https://api.twitter.com/2/users/%v/owned_lists"
+	// Manage Lists
+	deleteListURL            = "https://api.twitter.com/2/lists/%v"
+	updateMetaDataForListURL = "https://api.twitter.com/2/lists/%v"
+	createNewListURL         = "https://api.twitter.com/2/lists"
+	// List Tweets lookup
+	lookUpListTweetsURL = "https://api.twitter.com/2/lists/%v/tweets"
 	// List members
 	undoListMembersURL    = "https://api.twitter.com/2/lists/%v/members/%v"
 	listMembersURL        = "https://api.twitter.com/2/lists/%v/members"
 	listsSpecifiedUserURL = "https://api.twitter.com/2/users/%v/list_memberships"
 	postListMembersURL    = "https://api.twitter.com/2/lists/%v/members"
-	// Manage Lists
-	deleteListURL            = "https://api.twitter.com/2/lists/%v"
-	updateMetaDataForListURL = "https://api.twitter.com/2/lists/%v"
-	createNewListURL         = "https://api.twitter.com/2/lists"
+	// List follows
+	undoListFollowsURL     = "https://api.twitter.com/2/users/%v/followed_lists/%v"
+	listFollowersURL       = "https://api.twitter.com/2/lists/%v/followers"
+	allListsUserFollowsURL = "https://api.twitter.com/2/users/%v/followed_lists"
+	postListFollowsURL     = "https://api.twitter.com/2/users/%v/followed_lists"
 	// Pinned Lists
 	undoPinnedListsURL = "https://api.twitter.com/2/users/%v/pinned_lists/%v"
 	pinnedListsURL     = "https://api.twitter.com/2/users/%v/pinned_lists"
@@ -113,3 +111,10 @@ const (
 	complianceJobURL       = "https://api.twitter.com/2/compliance/jobs/%v"
 	createComplianceJobURL = "https://api.twitter.com/2/compliance/jobs"
 )
+
+const (
+	// Manage Direct Message
+	createOneToOneDMURL = "https://api.twitter.com/2/dm_conversations/with/%v/messages"
+	createNewGroupDMURL = "https://api.twitter.com/2/dm_conversations/%v/messages"
+	postDMURL           = "https://api.twitter.com/2/dm_conversations"
+)
diff --git a/expansion.go b/expansion.go
index 81546fd..784f9a7 100644
--- a/expansion.go
+++ b/expansion.go
@@ -3,22 +3,30 @@ package gotwtr
 type Expansion string
 
 const (
-	ExpansionAttachmentsPollIDs         Expansion = "attachments.poll_ids"
-	ExpansionAttachmentsMediaKeys       Expansion = "attachments.media_keys"
+	// Tweet payloads
 	ExpansionAuthorID                   Expansion = "author_id"
-	ExpansionEntitiesMentionsUserName   Expansion = "entities.mentions.username"
-	ExpansionGeoPlaceID                 Expansion = "geo.place_id"
-	ExpansionInReplyToUserID            Expansion = "in_reply_to_user_id"
 	ExpansionReferencedTweetsID         Expansion = "referenced_tweets.id"
+	ExpansionEditHistoryTweetIDs        Expansion = "edit_history_tweet_ids"
+	ExpansionInReplyToUserID            Expansion = "in_reply_to_user_id"
+	ExpansionAttachmentsMediaKeys       Expansion = "attachments.media_keys"
+	ExpansionAttachmentsPollIDs         Expansion = "attachments.poll_ids"
+	ExpansionGeoPlaceID                 Expansion = "geo.place_id"
+	ExpansionEntitiesMentionsUserName   Expansion = "entities.mentions.username"
 	ExpansionReferencedTweetsIDAuthorID Expansion = "referenced_tweets.id.author_id"
-	ExpansionPinnedTweetID              Expansion = "pinned_tweet_id"
-	ExpansionInvitedUserIDs             Expansion = "invited_user_ids"
-	ExpansionSpeakerIDs                 Expansion = "speaker_ids"
-	ExpansionCreatorID                  Expansion = "creator_id"
-	ExpansionHostIDs                    Expansion = "host_ids"
 	ExpansionContextAnnotations         Expansion = "context_annotations"
-	ExpansionTopicIDs                   Expansion = "topic_ids"
-	ExpansionOwnerID                    Expansion = "list.owner_id"
+	// USer payloads
+	ExpansionPinnedTweetID Expansion = "pinned_tweet_id"
+	// Direct Message event payloads with attachments.media_keys + referenced_tweets.id
+	ExpansionSenderID       Expansion = "sender_id"
+	ExpansionParticipantIDs Expansion = "participant_ids"
+	// Space payloads
+	ExpansionInvitedUserIDs Expansion = "invited_user_ids"
+	ExpansionSpeakerIDs     Expansion = "speaker_ids"
+	ExpansionCreatorID      Expansion = "creator_id"
+	ExpansionHostIDs        Expansion = "host_ids"
+	ExpansionTopicIDs       Expansion = "topic_ids"
+	// List payloads
+	ExpansionOwnerID Expansion = "owner_id"
 )
 
 func expansionsToString(es []Expansion) []string {
diff --git a/filtered_stream.go b/filtered_stream.go
index acccf2d..89dccc0 100644
--- a/filtered_stream.go
+++ b/filtered_stream.go
@@ -138,10 +138,10 @@ func (s *ConnectToStream) retry(req *http.Request) {
 		var connectToStream ConnectToStreamResponse
 		err := dec.Decode(&connectToStream)
 		if err != nil {
+			s.errCh <- err
 			if err == io.EOF {
 				break
 			}
-			s.errCh <- err
 		}
 		s.ch <- connectToStream
 	}
diff --git a/manage_direct_message.go b/manage_direct_message.go
new file mode 100644
index 0000000..1f982db
--- /dev/null
+++ b/manage_direct_message.go
@@ -0,0 +1,117 @@
+package gotwtr
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+)
+
+func createOneToOneDM(ctx context.Context, c *client, participantID string, body *CreateOneToOneDMBody) (*CreateOneToOneDMResponse, error) {
+	if participantID == "" {
+		return nil, errors.New("create a one to one DM: participant id parameter is required")
+	}
+	ep := fmt.Sprintf(createOneToOneDMURL, participantID)
+	j, err := json.Marshal(body)
+	if err != nil {
+		return nil, errors.New("create a one to one DM: can not marshal")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep, bytes.NewBuffer((j)))
+	if err != nil {
+		return nil, fmt.Errorf("create a one to one DM with ctx: %w", err)
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("create a one to one DM response: %w", err)
+	}
+	defer resp.Body.Close()
+
+	var createOneToOneDM CreateOneToOneDMResponse
+	if err := json.NewDecoder(resp.Body).Decode(&createOneToOneDM); err != nil {
+		return nil, fmt.Errorf("create a one to one DM decode: %w", err)
+	}
+	if resp.StatusCode != http.StatusCreated {
+		return &createOneToOneDM, &HTTPError{
+			APIName: "create one to one DM",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+	return &createOneToOneDM, nil
+}
+
+func createNewGroupDM(ctx context.Context, c *client, conversationID string, body *CreateNewGroupDMBody) (*CreateNewGroupDMResponse, error) {
+	if conversationID == "" {
+		return nil, errors.New("create new group DM: conversation id parameter is required")
+	}
+	ep := fmt.Sprintf(createNewGroupDMURL, conversationID)
+	j, err := json.Marshal(body)
+	if err != nil {
+		return nil, errors.New("create new group DM: can not marshal")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep, bytes.NewBuffer((j)))
+	if err != nil {
+		return nil, fmt.Errorf("create new group DM with ctx: %w", err)
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("create new group DM response: %w", err)
+	}
+	defer resp.Body.Close()
+
+	var createNewGroupDM CreateNewGroupDMResponse
+	if err := json.NewDecoder(resp.Body).Decode(&createNewGroupDM); err != nil {
+		return nil, fmt.Errorf("create new group DM decode: %w", err)
+	}
+	if resp.StatusCode != http.StatusCreated {
+		return &createNewGroupDM, &HTTPError{
+			APIName: "create new group DM",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+	return &createNewGroupDM, nil
+}
+
+func postDM(ctx context.Context, c *client, body *PostDMBody) (*PostDMResponse, error) {
+	j, err := json.Marshal(body)
+	if err != nil {
+		return nil, errors.New("post DM: can not marshal")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, postDMURL, bytes.NewBuffer((j)))
+	if err != nil {
+		return nil, fmt.Errorf("post DM with ctx: %w", err)
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("post DM response: %w", err)
+	}
+	defer resp.Body.Close()
+
+	var postDM PostDMResponse
+	if err := json.NewDecoder(resp.Body).Decode(&postDM); err != nil {
+		return nil, fmt.Errorf("post DM decode: %w", err)
+	}
+	if resp.StatusCode != http.StatusCreated {
+		return &postDM, &HTTPError{
+			APIName: "post DM",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+	return &postDM, nil
+}
diff --git a/media.go b/media.go
index 27bac69..9e3680b 100644
--- a/media.go
+++ b/media.go
@@ -8,35 +8,37 @@ type MediaField string
 */
 
 const (
-	MediaFieldDurationMS       MediaField = "duration_ms"
-	MediaFieldHeight           MediaField = "height"
 	MediaFieldMediaKey         MediaField = "media_key"
-	MediaFieldPreviewImageURL  MediaField = "preview_image_url"
 	MediaFieldType             MediaField = "type"
 	MediaFieldURL              MediaField = "url"
-	MediaFieldWidth            MediaField = "width"
-	MediaFieldPublicMetrics    MediaField = "public_metrics"
+	MediaFieldDurationMS       MediaField = "duration_ms"
+	MediaFieldHeight           MediaField = "height"
 	MediaFieldNonPublicMetrics MediaField = "non_public_metrics"
 	MediaFieldOrganicMetrics   MediaField = "organic_metrics"
+	MediaFieldPreviewImageURL  MediaField = "preview_image_url"
 	MediaFieldPromotedMetrics  MediaField = "promoted_metrics"
+	MediaFieldPublicMetrics    MediaField = "public_metrics"
+	MediaFieldWidth            MediaField = "width"
 	MediaFieldAltText          MediaField = "alt_text"
+	MediaFieldVariants         MediaField = "variants"
 )
 
 type Media struct {
-	MediaKey         string        `json:"media_key"`
-	Type             string        `json:"type"`
-	URL              string        `json:"url,omitempty"`
-	DurationMs       int           `json:"duration_ms,omitempty"`
-	Height           int           `json:"height,omitempty"`
-	NonPublicMetrics *MediaMetrics `json:"non_public_metrics,omitempty"`
-	OrganicMetrics   *MediaMetrics `json:"organic_metrics,omitempty"`
-	PreviewImageURL  string        `json:"preview_image_url,omitempty"`
-	PromotedMetrics  *MediaMetrics `json:"promoted_metrics,omitempty"`
-	PublicMetrics    *MediaMetrics `json:"public_metrics,omitempty"`
-	Width            int           `json:"width,omitempty"`
-	AltText          string        `json:"alt_text,omitempty"`
-	MediaIDs         []string      `json:"media_ids,omitempty"`
-	TaggedUserIDs    []string      `json:"tagged_user_ids,omitempty"`
+	MediaKey         string         `json:"media_key"`
+	Type             string         `json:"type"`
+	URL              string         `json:"url,omitempty"`
+	DurationMs       int            `json:"duration_ms,omitempty"`
+	Height           int            `json:"height,omitempty"`
+	NonPublicMetrics *MediaMetrics  `json:"non_public_metrics,omitempty"`
+	OrganicMetrics   *MediaMetrics  `json:"organic_metrics,omitempty"`
+	PreviewImageURL  string         `json:"preview_image_url,omitempty"`
+	PromotedMetrics  *MediaMetrics  `json:"promoted_metrics,omitempty"`
+	PublicMetrics    *MediaMetrics  `json:"public_metrics,omitempty"`
+	Width            int            `json:"width,omitempty"`
+	AltText          string         `json:"alt_text,omitempty"`
+	MediaIDs         []string       `json:"media_ids,omitempty"`
+	TaggedUserIDs    []string       `json:"tagged_user_ids,omitempty"`
+	Variants         []MediaVariant `json:"variants,omitempty"`
 }
 
 type MediaMetrics struct {
@@ -48,6 +50,12 @@ type MediaMetrics struct {
 	ViewCount        int `json:"view_count,omitempty"`
 }
 
+type MediaVariant struct {
+	BitRate     int    `json:"bit_rate"`
+	ContentType string `json:"content_type"`
+	URL         string `json:"url"`
+}
+
 func mediaFieldsToString(mfs []MediaField) []string {
 	slice := make([]string, len(mfs))
 	for i, mf := range mfs {
diff --git a/place.go b/place.go
index 4f0b8cd..0a6fdfd 100644
--- a/place.go
+++ b/place.go
@@ -8,12 +8,12 @@ type PlaceField string
 */
 
 const (
+	PlaceFieldFullName        PlaceField = "full_name"
+	PlaceFieldID              PlaceField = "id"
 	PlaceFieldContainedWithin PlaceField = "contained_within"
 	PlaceFieldCountry         PlaceField = "country"
 	PlaceFieldCountryCode     PlaceField = "country_code"
-	PlaceFieldFullName        PlaceField = "full_name"
 	PlaceFieldGeo             PlaceField = "geo"
-	PlaceFieldID              PlaceField = "id"
 	PlaceFieldName            PlaceField = "name"
 	PlaceFieldPlaceType       PlaceField = "place_type"
 )
diff --git a/poll.go b/poll.go
index e9bcaed..cbc12fe 100644
--- a/poll.go
+++ b/poll.go
@@ -8,10 +8,10 @@ type PollField string
 */
 
 const (
-	PollFieldDurationMinutes PollField = "duration_minutes"
-	PollFieldEndDateTime     PollField = "end_datetime"
 	PollFieldID              PollField = "id"
 	PollFieldOptions         PollField = "options"
+	PollFieldDurationMinutes PollField = "duration_minutes"
+	PollFieldEndDateTime     PollField = "end_datetime"
 	PollFieldVotingStatus    PollField = "voting_status"
 )
 
diff --git a/tweet.go b/tweet.go
index f442288..8c97aaa 100644
--- a/tweet.go
+++ b/tweet.go
@@ -13,31 +13,35 @@ type TweetField string
 */
 
 const (
+	TweetFieldID                 TweetField = "id"
+	TweetFieldText               TweetField = "text"
+	TweetEditHistoryIDs          TweetField = "edit_history_tweet_ids"
 	TweetFieldAttachments        TweetField = "attachments"
 	TweetFieldAuthorID           TweetField = "author_id"
-	TweetFieldCreatedAt          TweetField = "created_at"
-	TweetFieldConversationID     TweetField = "conversation_id"
 	TweetFieldContextAnnotations TweetField = "context_annotations"
+	TweetFieldConversationID     TweetField = "conversation_id"
+	TweetFieldCreatedAt          TweetField = "created_at"
+	TweetFieldEditControls       TweetField = "edit_controls"
 	TweetFieldEntities           TweetField = "entities"
-	TweetFieldGeo                TweetField = "geo"
-	TweetFieldID                 TweetField = "id"
 	TweetFieldInReplyToUserID    TweetField = "in_reply_to_user_id"
 	TweetFieldLanguage           TweetField = "lang"
-	TweetFieldMaxResults         TweetField = "max_results"
 	TweetFieldNonPublicMetrics   TweetField = "non_public_metrics"
-	TweetFieldPublicMetrics      TweetField = "public_metrics"
 	TweetFieldOrganicMetrics     TweetField = "organic_metrics"
-	TweetFieldPromotedMetrics    TweetField = "promoted_metrics"
 	TweetFieldPossiblySensitve   TweetField = "possibly_sensitive"
+	TweetFieldPromotedMetrics    TweetField = "promoted_metrics"
+	TweetFieldPublicMetrics      TweetField = "public_metrics"
 	TweetFieldReferencedTweets   TweetField = "referenced_tweets"
+	TweetReplySettings           TweetField = "reply_settings"
 	TweetFieldSource             TweetField = "source"
-	TweetFieldText               TweetField = "text"
 	TweetFieldWithHeld           TweetField = "withheld"
+	TweetFieldGeo                TweetField = "geo"
+	TweetFieldMaxResults         TweetField = "max_results"
 )
 
 type Tweet struct {
 	ID                 string                    `json:"id"`
 	Text               string                    `json:"text"`
+	EditHistoryIDs     []string                  `json:"edit_history_tweet_ids"`
 	Attachments        *TweetAttachment          `json:"attachments,omitempty"`
 	AuthorID           string                    `json:"author_id,omitempty"`
 	ContextAnnotations []*TweetContextAnnotation `json:"context_annotations,omitempty"`
@@ -483,6 +487,8 @@ type TweetsUserLiked struct {
 	Lang               string                               `json:"lang,omitempty"`
 	ReplySettings      string                               `json:"reply_settings,omitempty"`
 	Source             string                               `json:"source,omitempty"`
+	EditHistoryIDs     []string                             `json:"edit_history_ids,omitempty"`
+	EditControls       *TweetsUserLikedEditControls         `json:"edit_controls,omitempty"`
 }
 
 type TweetsUserLikedReferencedTweets struct {
@@ -687,3 +693,9 @@ type RemoveBookmarkOfTweetResponse struct {
 type RemoveBookmarkOfTweetData struct {
 	Bookmarks bool `json:"bookmarks"`
 }
+
+type TweetsUserLikedEditControls struct {
+	EditRemaining  int    `json:"edit_remaining"`
+	IsEditEligible bool   `json:"is_edit_eligible"`
+	EditableUntil  string `json:"editable_until"`
+}
diff --git a/user.go b/user.go
index 1000d37..74271a3 100644
--- a/user.go
+++ b/user.go
@@ -12,18 +12,18 @@ type UserField string
 */
 
 const (
+	UserFieldID              UserField = "id"
+	UserFieldName            UserField = "name"
+	UserFieldUserName        UserField = "username"
 	UserFieldCreatedAt       UserField = "created_at"
 	UserFieldDescription     UserField = "description"
 	UserFieldEntities        UserField = "entities"
-	UserFieldID              UserField = "id"
 	UserFieldLocation        UserField = "location"
-	UserFieldName            UserField = "name"
 	UserFieldPinnedTweetID   UserField = "pinned_tweet_id"
 	UserFieldProfileImageURL UserField = "profile_image_url"
 	UserFieldProtected       UserField = "protected"
 	UserFieldPublicMetrics   UserField = "public_metrics"
 	UserFieldURL             UserField = "url"
-	UserFieldUserName        UserField = "username"
 	UserFieldVerified        UserField = "verified"
 	UserFieldWithHeld        UserField = "withhel"
 )


Test patch:
diff --git a/example_test.go b/example_test.go
index a178e3a..cb55c68 100644
--- a/example_test.go
+++ b/example_test.go
@@ -719,3 +719,43 @@ func ExampleClient_DeleteTweet() {
 		log.Fatal(err)
 	}
 }
+
+func ExampleClient_CreateOneToOneDM() {
+	client := gotwtr.New("key")
+	d, err := client.CreateOneToOneDM(context.Background(), "participant_id", &gotwtr.CreateOneToOneDMBody{
+		Text: "Hello World",
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+	log.Println(d)
+}
+
+func ExampleClient_CreateNewGroupDM() {
+	client := gotwtr.New("key")
+	d, err := client.CreateNewGroupDM(context.Background(), "conversation_id", &gotwtr.CreateNewGroupDMBody{
+		Text: "Hello World",
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+	log.Println(d)
+}
+
+func ExampleClient_PostDM() {
+	client := gotwtr.New("key")
+	d, err := client.PostDM(context.Background(), &gotwtr.PostDMBody{
+		ConversationType: "Group",
+		ParticipantIDs: []string{
+			"944480690",
+			"906948460078698496",
+		},
+		Message: &gotwtr.Message{
+			Text: "Hello to you two, this is a new group conversation",
+		},
+	})
+	if err != nil {
+		log.Fatal(err)
+	}
+	log.Println(d)
+}
diff --git a/manage_direct_message_test.go b/manage_direct_message_test.go
new file mode 100644
index 0000000..1ed08ec
--- /dev/null
+++ b/manage_direct_message_test.go
@@ -0,0 +1,290 @@
+package gotwtr_test
+
+import (
+	"context"
+	_ "embed"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/sivchari/gotwtr"
+)
+
+//go:embed testdata/create_one_to_one_dm.json
+var createOneToOneDM []byte
+
+//go:embed testdata/create_new_group_dm.json
+var createNewGroupDM []byte
+
+//go:embed testdata/post_dm.json
+var postDM []byte
+
+//go:embed testdata/403.json
+var forbidden []byte
+
+func Test_CreateOneToOneDM(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		ctx           context.Context
+		client        *http.Client
+		participantID string
+		body          *gotwtr.CreateOneToOneDMBody
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.CreateOneToOneDMResponse
+		wantErr bool
+	}{
+		{
+			name: "201 Create a one to one dm",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					return &http.Response{
+						StatusCode: http.StatusCreated,
+						Body:       io.NopCloser(strings.NewReader(string(createOneToOneDM))),
+					}
+				}),
+				participantID: "2244994945",
+				body: &gotwtr.CreateOneToOneDMBody{
+					Text: "This is a one-to-one Direct Message with an attachment",
+					Attachments: []gotwtr.MessageAttachment{
+						{
+							MediaID: "1455952740635586573",
+						},
+					},
+				},
+			},
+			want: &gotwtr.CreateOneToOneDMResponse{
+				DMConversationID: "1346889436626259968",
+				DMEventID:        "128341038123",
+			},
+			wantErr: false,
+		},
+		{
+			name: "403 authentication error",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					return &http.Response{
+						StatusCode: http.StatusForbidden,
+						Body:       io.NopCloser(strings.NewReader(string(forbidden))),
+					}
+				}),
+				participantID: "2244994945",
+				body: &gotwtr.CreateOneToOneDMBody{
+					Text: "This is a one-to-one Direct Message with an attachment",
+					Attachments: []gotwtr.MessageAttachment{
+						{
+							MediaID: "1455952740635586573",
+						},
+					},
+				},
+			},
+			want: &gotwtr.CreateOneToOneDMResponse{
+				Errors: []*gotwtr.APIResponseError{
+					{
+						Title:  "Unsupported Authentication",
+						Detail: "Authenticating with OAuth 2.0 Application-Only is forbidden for this endpoint.  Supported authentication types are [OAuth 1.0a User Context, OAuth 2.0 User Context].",
+						Type:   "https://api.twitter.com/2/problems/unsupported-authentication",
+						Status: 403,
+					},
+				},
+			},
+			wantErr: true,
+		},
+	}
+	for i, tt := range tests {
+		i := i
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("test-key", gotwtr.WithHTTPClient(tt.args.client))
+			got, err := c.CreateOneToOneDM(tt.args.ctx, tt.args.participantID, tt.args.body)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.CreateOneToOneDM() index = %v error = %v, wantErr %v", i, err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("client.CreateNewOneToOneDM() index = %v mismatch (-want +got):\n%s", i, diff)
+				return
+			}
+		})
+	}
+}
+func Test_CreateNewGroupDM(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		ctx            context.Context
+		client         *http.Client
+		conversationID string
+		body           *gotwtr.CreateNewGroupDMBody
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.CreateNewGroupDMResponse
+		wantErr bool
+	}{
+		{
+			name: "201 Create New Group",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					return &http.Response{
+						StatusCode: http.StatusCreated,
+						Body:       io.NopCloser(strings.NewReader(string(createNewGroupDM))),
+					}
+				}),
+				conversationID: "1346889436626259968",
+				body: &gotwtr.CreateNewGroupDMBody{
+					Text: "Adding a Direct Message to a conversation by referencing the conversation ID. This method supports both one-to-one and group conversations.",
+				},
+			},
+			want: &gotwtr.CreateNewGroupDMResponse{
+				DMConversationID: "1346889436626259968",
+				DMEventID:        "128341038123",
+			},
+			wantErr: false,
+		},
+		{
+			name: "403 authentication error",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					return &http.Response{
+						StatusCode: http.StatusForbidden,
+						Body:       io.NopCloser(strings.NewReader(string(forbidden))),
+					}
+				}),
+				conversationID: "1346889436626259968",
+				body: &gotwtr.CreateNewGroupDMBody{
+					Text: "Adding a Direct Message to a conversation by referencing the conversation ID. This method supports both one-to-one and group conversations.",
+				},
+			},
+			want: &gotwtr.CreateNewGroupDMResponse{
+				Errors: []*gotwtr.APIResponseError{
+					{
+						Title:  "Unsupported Authentication",
+						Detail: "Authenticating with OAuth 2.0 Application-Only is forbidden for this endpoint.  Supported authentication types are [OAuth 1.0a User Context, OAuth 2.0 User Context].",
+						Type:   "https://api.twitter.com/2/problems/unsupported-authentication",
+						Status: 403,
+					},
+				},
+			},
+			wantErr: true,
+		},
+	}
+	for i, tt := range tests {
+		i := i
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("test-key", gotwtr.WithHTTPClient(tt.args.client))
+			got, err := c.CreateNewGroupDM(tt.args.ctx, tt.args.conversationID, tt.args.body)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.CreateNewGroupDM() index = %v error = %v, wantErr %v", i, err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("client.CreateNewGroupDM() index = %v mismatch (-want +got):\n%s", i, diff)
+				return
+			}
+		})
+	}
+}
+func Test_PostDM(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		ctx    context.Context
+		client *http.Client
+		body   *gotwtr.PostDMBody
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.PostDMResponse
+		wantErr bool
+	}{
+		{
+			name: "201 Post DM",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					return &http.Response{
+						StatusCode: http.StatusCreated,
+						Body:       io.NopCloser(strings.NewReader(string(postDM))),
+					}
+				}),
+				body: &gotwtr.PostDMBody{
+					ConversationType: "Group",
+					ParticipantIDs: []string{
+						"944480690",
+						"906948460078698496",
+					},
+					Message: &gotwtr.Message{
+						Text: "Hello to you two, this is a new group conversation",
+					},
+				},
+			},
+			want: &gotwtr.PostDMResponse{
+				DMConversationID: "1346889436626259968",
+				DMEventID:        "128341038123",
+			},
+			wantErr: false,
+		},
+		{
+			name: "403 authentication error",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					return &http.Response{
+						StatusCode: http.StatusForbidden,
+						Body:       io.NopCloser(strings.NewReader(string(forbidden))),
+					}
+				}),
+				body: &gotwtr.PostDMBody{
+					ConversationType: "Group",
+					ParticipantIDs: []string{
+						"944480690",
+						"906948460078698496",
+					},
+					Message: &gotwtr.Message{
+						Text: "Hello to you two, this is a new group conversation",
+					},
+				},
+			},
+			want: &gotwtr.PostDMResponse{
+				Errors: []*gotwtr.APIResponseError{
+					{
+						Title:  "Unsupported Authentication",
+						Detail: "Authenticating with OAuth 2.0 Application-Only is forbidden for this endpoint.  Supported authentication types are [OAuth 1.0a User Context, OAuth 2.0 User Context].",
+						Type:   "https://api.twitter.com/2/problems/unsupported-authentication",
+						Status: 403,
+					},
+				},
+			},
+			wantErr: true,
+		},
+	}
+	for i, tt := range tests {
+		i := i
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("test-key", gotwtr.WithHTTPClient(tt.args.client))
+			got, err := c.PostDM(tt.args.ctx, tt.args.body)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.PostDM() index = %v error = %v, wantErr %v", i, err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("client.PostDM() index = %v mismatch (-want +got):\n%s", i, diff)
+				return
+			}
+		})
+	}
+}
diff --git a/testdata/403.json b/testdata/403.json
new file mode 100644
index 0000000..f7ed22c
--- /dev/null
+++ b/testdata/403.json
@@ -0,0 +1,10 @@
+{
+  "errors": [
+    {
+      "title": "Unsupported Authentication",
+      "detail": "Authenticating with OAuth 2.0 Application-Only is forbidden for this endpoint.  Supported authentication types are [OAuth 1.0a User Context, OAuth 2.0 User Context].",
+      "type": "https://api.twitter.com/2/problems/unsupported-authentication",
+      "status": 403
+    }
+  ]
+}
diff --git a/testdata/create_new_group_dm.json b/testdata/create_new_group_dm.json
new file mode 100644
index 0000000..656123a
--- /dev/null
+++ b/testdata/create_new_group_dm.json
@@ -0,0 +1,4 @@
+{
+  "dm_conversation_id": "1346889436626259968",
+  "dm_event_id": "128341038123"
+}
diff --git a/testdata/create_one_to_one_dm.json b/testdata/create_one_to_one_dm.json
new file mode 100644
index 0000000..656123a
--- /dev/null
+++ b/testdata/create_one_to_one_dm.json
@@ -0,0 +1,4 @@
+{
+  "dm_conversation_id": "1346889436626259968",
+  "dm_event_id": "128341038123"
+}
diff --git a/testdata/post_dm.json b/testdata/post_dm.json
new file mode 100644
index 0000000..656123a
--- /dev/null
+++ b/testdata/post_dm.json
@@ -0,0 +1,4 @@
+{
+  "dm_conversation_id": "1346889436626259968",
+  "dm_event_id": "128341038123"
+}