From c8118f3cb6895d113c5bda6f1a803fa3f541077a Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Wed, 24 Jun 2026 06:15:28 +0300 Subject: [PATCH] Add optional Xquik backend --- README.md | 31 +++++++-- action.yaml | 32 ++++++--- dist/index.js | 150 +++++++++++++++++++++++++++++++++++------- docs/example-usage.md | 25 ++++++- docs/inputs.md | 24 +++++-- index.js | 150 +++++++++++++++++++++++++++++++++++------- index.test.js | 88 ++++++++++++++++++++++++- 7 files changed, 433 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 308fad2..ef4ba0e 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,14 @@ it can be used to announce new releases, share updates, or integrate your github | input | description | required | default | |----------------|-------------------------------------|----------|---------------| -| `appKey` | the x api app key | yes | - | -| `appSecret` | the x api app secret | yes | - | -| `accessToken` | the x api access token | yes | - | -| `accessSecret` | the x api access secret | yes | - | +| `backend` | posting backend: `x-api` or `xquik` | no | `x-api` | +| `appKey` | the x api app key for `x-api` | for `x-api` | - | +| `appSecret` | the x api app secret for `x-api` | for `x-api` | - | +| `accessToken` | the x api access token for `x-api` | for `x-api` | - | +| `accessSecret` | the x api access secret for `x-api` | for `x-api` | - | +| `xquikApiKey` | the xquik api key for `xquik` | for `xquik` | - | +| `xquikAccount` | connected x account for `xquik` | for `xquik` | - | +| `xquikBaseUrl` | xquik api base url | no | `https://xquik.com/api/v1` | | `message` | the message to post to x | yes | 'Hello, world!' | | `community-id` | the id of the community to post to | no | null | @@ -79,6 +83,22 @@ jobs: community-id: '123456789' ``` +### xquik backend + +Use `backend: xquik` when your posting account is connected in Xquik and you +want the action to call Xquik instead of passing X app credentials to the +workflow. + +```yaml +- name: Post to X with Xquik + uses: captradeoff/x-post-action@v1 + with: + backend: xquik + xquikApiKey: ${{ secrets.XQUIK_API_KEY }} + xquikAccount: ${{ vars.XQUIK_ACCOUNT }} + message: 'New release ${{ github.event.release.tag_name }} is now available!' +``` + ## ⚙️ how it works this action uses the twitter api v2 (via the `twitter-api-v2` npm package) to post messages to x. it supports: @@ -87,6 +107,9 @@ this action uses the twitter api v2 (via the `twitter-api-v2` npm package) to po - posting to a specific x community (optional) - returns the post id for further processing +When `backend` is `xquik`, the action sends the same `message` and optional +`community-id` to `POST /x/tweets` on the configured Xquik API base URL. + ## 🔑 setting up x api credentials to use this action, you'll need to create an x developer account and set up an app: diff --git a/action.yaml b/action.yaml index 5c29ca4..91c5796 100644 --- a/action.yaml +++ b/action.yaml @@ -5,18 +5,32 @@ branding: icon: message-circle color: black inputs: + backend: + description: 'Posting backend: x-api or xquik' + required: false + default: 'x-api' appKey: - description: 'The app key' - required: true + description: 'The app key. Required when backend is x-api' + required: false appSecret: - description: 'The app secret' - required: true + description: 'The app secret. Required when backend is x-api' + required: false accessToken: - description: 'The access token' - required: true + description: 'The access token. Required when backend is x-api' + required: false accessSecret: - description: 'The access secret' - required: true + description: 'The access secret. Required when backend is x-api' + required: false + xquikApiKey: + description: 'The Xquik API key. Required when backend is xquik' + required: false + xquikAccount: + description: 'The connected X account username or ID. Required when backend is xquik' + required: false + xquikBaseUrl: + description: 'The Xquik API base URL' + required: false + default: 'https://xquik.com/api/v1' message: description: 'The message to post' required: true @@ -30,4 +44,4 @@ outputs: description: 'The ID of the post' runs: using: 'node20' - main: 'dist/index.js' \ No newline at end of file + main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index a8467c5..1ea40f9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -7,39 +7,145 @@ const core = __nccwpck_require__(7484); const { TwitterApi } = __nccwpck_require__(4455); +const X_API_BACKEND = "x-api"; +const XQUIK_BACKEND = "xquik"; +const DEFAULT_XQUIK_BASE_URL = "https://xquik.com/api/v1"; + +function getRequiredInput(name, message) { + const value = core.getInput(name); + if (!value) { + throw new Error(message); + } + return value; +} + +function normalizeBackend(value) { + return (value || X_API_BACKEND).trim().toLowerCase(); +} + +function normalizeBaseUrl(value) { + return (value || DEFAULT_XQUIK_BASE_URL).replace(/\/+$/, ""); +} + +function buildTweetProps(communityId) { + const tweetProps = {}; + + if (communityId) { + tweetProps.community_id = communityId; + } + + return tweetProps; +} + +async function postWithXApi(message, communityId) { + const appKey = getRequiredInput("appKey", "appKey is required when backend is x-api"); + const appSecret = getRequiredInput("appSecret", "appSecret is required when backend is x-api"); + const accessToken = getRequiredInput("accessToken", "accessToken is required when backend is x-api"); + const accessSecret = getRequiredInput("accessSecret", "accessSecret is required when backend is x-api"); + + console.log("Creating TwitterApi client"); + const userClient = new TwitterApi({ + appKey, + appSecret, + accessToken, + accessSecret, + }); + + const tweetProps = buildTweetProps(communityId); + + console.log("Sending tweet:", message, "with props:", JSON.stringify(tweetProps)); + const result = await userClient.v2.tweet(message, tweetProps); + console.log("Tweet #", result.data.id, ": ", result.data.text); + core.setOutput("post-id", result.data.id); + return result; +} + +async function readJsonResponse(response) { + try { + return await response.json(); + } catch { + return {}; + } +} + +function buildXquikPayload(message, communityId) { + const payload = { + account: getRequiredInput("xquikAccount", "xquikAccount is required when backend is xquik"), + text: message, + }; + + if (communityId) { + payload.community_id = communityId; + } + + return payload; +} + +function getXquikPostId(body) { + if (body && typeof body.tweetId === "string") { + return body.tweetId; + } + if (body && typeof body.id === "string") { + return body.id; + } + if (body && body.data && typeof body.data.id === "string") { + return body.data.id; + } + return ""; +} + +function formatXquikError(response, body) { + const detail = body && (body.message || body.error); + if (detail) { + return `Xquik request failed with ${response.status}: ${detail}`; + } + return `Xquik request failed with ${response.status}`; +} + +async function postWithXquik(message, communityId) { + const apiKey = getRequiredInput("xquikApiKey", "xquikApiKey is required when backend is xquik"); + const baseUrl = normalizeBaseUrl(core.getInput("xquikBaseUrl")); + const payload = buildXquikPayload(message, communityId); + const response = await fetch(`${baseUrl}/x/tweets`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(payload), + }); + const body = await readJsonResponse(response); + + if (!response.ok) { + throw new Error(formatXquikError(response, body)); + } + + const postId = getXquikPostId(body); + if (postId) { + core.setOutput("post-id", postId); + } + return body; +} + // Export the function for testing async function postTweet() { try { // Log debug information for troubleshooting console.log("Starting postTweet function"); - - // Get input parameters with debug logging - const appKey = core.getInput("appKey"); - const appSecret = core.getInput("appSecret"); - const accessToken = core.getInput("accessToken"); - const accessSecret = core.getInput("accessSecret"); - - console.log("Creating TwitterApi client"); - const userClient = new TwitterApi({ - appKey, - appSecret, - accessToken, - accessSecret, - }); + const backend = normalizeBackend(core.getInput("backend")); const message = core.getInput("message"); const communityId = core.getInput("community-id"); - const tweetProps = {}; - if (communityId) { - tweetProps.community_id = communityId; + if (backend === XQUIK_BACKEND) { + return await postWithXquik(message, communityId); } - console.log("Sending tweet:", message, "with props:", JSON.stringify(tweetProps)); - const result = await userClient.v2.tweet(message, tweetProps); - console.log("Tweet #", result.data.id, ": ", result.data.text); - core.setOutput("post-id", result.data.id); - return result; + if (backend === X_API_BACKEND) { + return await postWithXApi(message, communityId); + } + + throw new Error(`Unsupported backend "${backend}". Use "x-api" or "xquik".`); } catch (error) { console.error("Error in postTweet:", error); // More detailed error reporting diff --git a/docs/example-usage.md b/docs/example-usage.md index e31e82d..80bcac2 100644 --- a/docs/example-usage.md +++ b/docs/example-usage.md @@ -26,6 +26,29 @@ jobs: message: 'hello from github actions!' ``` +## xquik backend + +post through a connected Xquik account: + +```yaml +name: post to x with xquik + +on: + workflow_dispatch: + +jobs: + post: + runs-on: ubuntu-latest + steps: + - name: post to x with xquik + uses: captradeoff/x-post-action@v1 + with: + backend: xquik + xquikApiKey: ${{ secrets.XQUIK_API_KEY }} + xquikAccount: ${{ vars.XQUIK_ACCOUNT }} + message: 'hello from github actions!' +``` + ## announce new releases automatically post when a new release is published: @@ -118,4 +141,4 @@ jobs: accessToken: ${{ secrets.X_ACCESS_TOKEN }} accessSecret: ${{ secrets.X_ACCESS_SECRET }} message: 'build successful for commit ${{ github.sha }} on branch ${{ github.ref_name }}. all tests passed! ✅' -``` \ No newline at end of file +``` diff --git a/docs/inputs.md b/docs/inputs.md index 5014da3..597e5e8 100644 --- a/docs/inputs.md +++ b/docs/inputs.md @@ -4,10 +4,14 @@ the x post action accepts the following inputs: | input | description | required | default | |----------------|-------------------------------------|----------|---------------| -| `appKey` | the x api app key | yes | - | -| `appSecret` | the x api app secret | yes | - | -| `accessToken` | the x api access token | yes | - | -| `accessSecret` | the x api access secret | yes | - | +| `backend` | posting backend: `x-api` or `xquik` | no | `x-api` | +| `appKey` | the x api app key for `x-api` | for `x-api` | - | +| `appSecret` | the x api app secret for `x-api` | for `x-api` | - | +| `accessToken` | the x api access token for `x-api` | for `x-api` | - | +| `accessSecret` | the x api access secret for `x-api` | for `x-api` | - | +| `xquikApiKey` | the xquik api key for `xquik` | for `xquik` | - | +| `xquikAccount` | connected x account for `xquik` | for `xquik` | - | +| `xquikBaseUrl` | xquik api base url | no | `https://xquik.com/api/v1` | | `message` | the message to post to x | yes | 'Hello, world!' | | `community-id` | the id of the community to post to | no | null | @@ -15,19 +19,25 @@ the x post action accepts the following inputs: ### api credentials -the four credential inputs (`appKey`, `appSecret`, `accessToken`, `accessSecret`) are required to authenticate with the x api. for security, these should be stored as secrets in your github repository. +the four credential inputs (`appKey`, `appSecret`, `accessToken`, `accessSecret`) are required when `backend` is `x-api`. for security, these should be stored as secrets in your github repository. learn more about [setting up x api credentials](./credentials.md). +### xquik credentials + +the `xquikApiKey` and `xquikAccount` inputs are required when `backend` is +`xquik`. store the api key in github secrets and use a repository variable for +the connected account name or id. + ### message the `message` input defines the content of your x post. this can be customized with dynamic content from your github workflow, such as: - release version numbers -- commit information +- commit information - repository data - custom messages ### community-id -the optional `community-id` parameter allows you to post directly to a specific x community. if not provided, the post will appear on your main timeline only. \ No newline at end of file +the optional `community-id` parameter allows you to post directly to a specific x community. if not provided, the post will appear on your main timeline only. diff --git a/index.js b/index.js index 8c40246..d476a40 100644 --- a/index.js +++ b/index.js @@ -1,39 +1,145 @@ const core = require("@actions/core"); const { TwitterApi } = require("twitter-api-v2"); +const X_API_BACKEND = "x-api"; +const XQUIK_BACKEND = "xquik"; +const DEFAULT_XQUIK_BASE_URL = "https://xquik.com/api/v1"; + +function getRequiredInput(name, message) { + const value = core.getInput(name); + if (!value) { + throw new Error(message); + } + return value; +} + +function normalizeBackend(value) { + return (value || X_API_BACKEND).trim().toLowerCase(); +} + +function normalizeBaseUrl(value) { + return (value || DEFAULT_XQUIK_BASE_URL).replace(/\/+$/, ""); +} + +function buildTweetProps(communityId) { + const tweetProps = {}; + + if (communityId) { + tweetProps.community_id = communityId; + } + + return tweetProps; +} + +async function postWithXApi(message, communityId) { + const appKey = getRequiredInput("appKey", "appKey is required when backend is x-api"); + const appSecret = getRequiredInput("appSecret", "appSecret is required when backend is x-api"); + const accessToken = getRequiredInput("accessToken", "accessToken is required when backend is x-api"); + const accessSecret = getRequiredInput("accessSecret", "accessSecret is required when backend is x-api"); + + console.log("Creating TwitterApi client"); + const userClient = new TwitterApi({ + appKey, + appSecret, + accessToken, + accessSecret, + }); + + const tweetProps = buildTweetProps(communityId); + + console.log("Sending tweet:", message, "with props:", JSON.stringify(tweetProps)); + const result = await userClient.v2.tweet(message, tweetProps); + console.log("Tweet #", result.data.id, ": ", result.data.text); + core.setOutput("post-id", result.data.id); + return result; +} + +async function readJsonResponse(response) { + try { + return await response.json(); + } catch { + return {}; + } +} + +function buildXquikPayload(message, communityId) { + const payload = { + account: getRequiredInput("xquikAccount", "xquikAccount is required when backend is xquik"), + text: message, + }; + + if (communityId) { + payload.community_id = communityId; + } + + return payload; +} + +function getXquikPostId(body) { + if (body && typeof body.tweetId === "string") { + return body.tweetId; + } + if (body && typeof body.id === "string") { + return body.id; + } + if (body && body.data && typeof body.data.id === "string") { + return body.data.id; + } + return ""; +} + +function formatXquikError(response, body) { + const detail = body && (body.message || body.error); + if (detail) { + return `Xquik request failed with ${response.status}: ${detail}`; + } + return `Xquik request failed with ${response.status}`; +} + +async function postWithXquik(message, communityId) { + const apiKey = getRequiredInput("xquikApiKey", "xquikApiKey is required when backend is xquik"); + const baseUrl = normalizeBaseUrl(core.getInput("xquikBaseUrl")); + const payload = buildXquikPayload(message, communityId); + const response = await fetch(`${baseUrl}/x/tweets`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(payload), + }); + const body = await readJsonResponse(response); + + if (!response.ok) { + throw new Error(formatXquikError(response, body)); + } + + const postId = getXquikPostId(body); + if (postId) { + core.setOutput("post-id", postId); + } + return body; +} + // Export the function for testing async function postTweet() { try { // Log debug information for troubleshooting console.log("Starting postTweet function"); - - // Get input parameters with debug logging - const appKey = core.getInput("appKey"); - const appSecret = core.getInput("appSecret"); - const accessToken = core.getInput("accessToken"); - const accessSecret = core.getInput("accessSecret"); - - console.log("Creating TwitterApi client"); - const userClient = new TwitterApi({ - appKey, - appSecret, - accessToken, - accessSecret, - }); + const backend = normalizeBackend(core.getInput("backend")); const message = core.getInput("message"); const communityId = core.getInput("community-id"); - const tweetProps = {}; - if (communityId) { - tweetProps.community_id = communityId; + if (backend === XQUIK_BACKEND) { + return await postWithXquik(message, communityId); + } + + if (backend === X_API_BACKEND) { + return await postWithXApi(message, communityId); } - console.log("Sending tweet:", message, "with props:", JSON.stringify(tweetProps)); - const result = await userClient.v2.tweet(message, tweetProps); - console.log("Tweet #", result.data.id, ": ", result.data.text); - core.setOutput("post-id", result.data.id); - return result; + throw new Error(`Unsupported backend "${backend}". Use "x-api" or "xquik".`); } catch (error) { console.error("Error in postTweet:", error); // More detailed error reporting diff --git a/index.test.js b/index.test.js index 92bb361..625877d 100644 --- a/index.test.js +++ b/index.test.js @@ -6,16 +6,20 @@ const { postTweet } = require('./index'); jest.mock('@actions/core'); jest.mock('twitter-api-v2'); +const originalFetch = global.fetch; + describe('X Post GitHub Action', () => { beforeEach(() => { // Clear mocks jest.clearAllMocks(); + global.fetch = originalFetch; }); test('should post a tweet successfully', async () => { // Setup mocks for inputs core.getInput = jest.fn(name => { switch (name) { + case 'backend': return ''; case 'appKey': return 'test-app-key'; case 'appSecret': return 'test-app-secret'; case 'accessToken': return 'test-access-token'; @@ -57,6 +61,7 @@ describe('X Post GitHub Action', () => { // Setup mocks for inputs core.getInput = jest.fn(name => { switch (name) { + case 'backend': return ''; case 'appKey': return 'test-app-key'; case 'appSecret': return 'test-app-secret'; case 'accessToken': return 'test-access-token'; @@ -83,9 +88,88 @@ describe('X Post GitHub Action', () => { expect(mockTweet).toHaveBeenCalledWith('Test message', { community_id: '987654321' }); }); + test('should post through Xquik when selected', async () => { + core.getInput = jest.fn(name => { + switch (name) { + case 'backend': return 'xquik'; + case 'xquikApiKey': return 'test-api-key'; + case 'xquikAccount': return 'release-account'; + case 'xquikBaseUrl': return 'https://xquik.com/api/v1/'; + case 'message': return 'Test message'; + case 'community-id': return '987654321'; + default: return ''; + } + }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ success: true, tweetId: 'xq-123' }), + }); + + const result = await postTweet(); + + expect(TwitterApi).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith('https://xquik.com/api/v1/x/tweets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test-api-key', + }, + body: JSON.stringify({ + account: 'release-account', + text: 'Test message', + community_id: '987654321', + }), + }); + expect(core.setOutput).toHaveBeenCalledWith('post-id', 'xq-123'); + expect(result).toEqual({ success: true, tweetId: 'xq-123' }); + }); + + test('should handle Xquik API errors', async () => { + core.getInput = jest.fn(name => { + switch (name) { + case 'backend': return 'xquik'; + case 'xquikApiKey': return 'test-api-key'; + case 'xquikAccount': return 'release-account'; + case 'message': return 'Test message'; + default: return ''; + } + }); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({ message: 'Account needs reconnect' }), + }); + + await expect(postTweet()).rejects.toThrow('Xquik request failed with 403: Account needs reconnect'); + expect(core.setFailed).toHaveBeenCalledWith('Xquik request failed with 403: Account needs reconnect'); + }); + + test('should reject unsupported backends', async () => { + core.getInput = jest.fn(name => { + switch (name) { + case 'backend': return 'unknown'; + case 'message': return 'Test message'; + default: return ''; + } + }); + + await expect(postTweet()).rejects.toThrow('Unsupported backend "unknown". Use "x-api" or "xquik".'); + expect(core.setFailed).toHaveBeenCalledWith('Unsupported backend "unknown". Use "x-api" or "xquik".'); + }); + test('should handle Twitter API errors', async () => { // Setup mocks - core.getInput = jest.fn(() => 'test value'); + core.getInput = jest.fn(name => { + switch (name) { + case 'backend': return ''; + case 'appKey': return 'test-app-key'; + case 'appSecret': return 'test-app-secret'; + case 'accessToken': return 'test-access-token'; + case 'accessSecret': return 'test-access-secret'; + case 'message': return 'Test message'; + default: return ''; + } + }); const mockTweet = jest.fn().mockRejectedValue(new Error('API error')); TwitterApi.mockImplementation(() => ({ @@ -111,4 +195,4 @@ describe('X Post GitHub Action', () => { // Verify error was reported expect(core.setFailed).toHaveBeenCalledWith('Input error'); }); -}); \ No newline at end of file +});