diff --git a/.env.example b/.env.example index 71cd099..4b4c14a 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ SLACK_APP_ID= # Printed by `pnpm slack:app create` SLACK_CLIENT_ID= # Printed by `pnpm slack:app create` SLACK_CLIENT_SECRET= # Printed by `pnpm slack:app create` SLACK_SIGNING_SECRET= # Printed by `pnpm slack:app create` +TELEGRAM_API_URL= # Defaults to https://api.telegram.org +TELEGRAM_BOT_TOKEN= # Telegram bot token from BotFather +TELEGRAM_BOT_USERNAME=ggtipbot # Telegram bot username without @ +TELEGRAM_WEBHOOK_SECRET_TOKEN= # Secret token passed to Telegram setWebhook TWITTER_ACCESS_TOKEN= # Twitter/X OAuth 1.0a access token for posting replies TWITTER_ACCESS_TOKEN_SECRET= # Twitter/X OAuth 1.0a access token secret for posting replies TWITTER_API_URL= # Defaults to https://api.twitter.com diff --git a/.github/workflows/preview_deploy.yml b/.github/workflows/preview_deploy.yml index af0dc8b..7fa9d77 100644 --- a/.github/workflows/preview_deploy.yml +++ b/.github/workflows/preview_deploy.yml @@ -9,6 +9,10 @@ on: sha: required: true type: string + telegram_bot_username: + default: ggtipbot + required: false + type: string twitter_bot_handle: default: tipbotgg required: false @@ -30,6 +34,10 @@ on: required: false SLACK_CONFIG_REFRESH_TOKEN: required: false + TELEGRAM_BOT_TOKEN: + required: true + TELEGRAM_WEBHOOK_SECRET_TOKEN: + required: true TWITTER_OAUTH_CLIENT_ID: required: true TWITTER_OAUTH_CLIENT_SECRET: @@ -47,6 +55,7 @@ env: SLACK_BOT_DISPLAY_NAME: Tipbot PR ${{ inputs.pr_number }} SLACK_COMMAND: /tippr${{ inputs.pr_number }} SLACK_APP_NAME: Tipbot PR ${{ inputs.pr_number }} + TELEGRAM_BOT_USERNAME: ${{ inputs.telegram_bot_username }} TWITTER_BOT_HANDLE: ${{ inputs.twitter_bot_handle }} WRANGLER_SEND_METRICS: false @@ -367,12 +376,14 @@ jobs: env: D1_ID: ${{ steps.d1.outputs.id }} PR_NUMBER: ${{ inputs.pr_number }} + TELEGRAM_BOT_USERNAME: ${{ env.TELEGRAM_BOT_USERNAME }} TWITTER_BOT_HANDLE: ${{ vars.TWITTER_BOT_HANDLE || env.TWITTER_BOT_HANDLE }} run: | sed -i.bak \ -e "s|__PREVIEW_NAME__|${PREVIEW_NAME}|g" \ -e "s|__PREVIEW_APEX__|${PREVIEW_APEX}|g" \ -e "s|__PREVIEW_PR_NUMBER__|${PR_NUMBER}|g" \ + -e "s|__PREVIEW_TELEGRAM_BOT_USERNAME__|${TELEGRAM_BOT_USERNAME}|g" \ -e "s|__PREVIEW_TWITTER_BOT_HANDLE__|${TWITTER_BOT_HANDLE}|g" \ -e "s|__PREVIEW_D1_ID__|${D1_ID}|g" \ wrangler.jsonc @@ -426,6 +437,8 @@ jobs: SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET + TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET_TOKEN TWITTER_OAUTH_CLIENT_ID TWITTER_OAUTH_CLIENT_SECRET env: @@ -435,6 +448,8 @@ jobs: SLACK_CLIENT_ID: ${{ steps.slack.outputs.client_id || steps.state.outputs.client_id }} SLACK_CLIENT_SECRET: ${{ steps.slack.outputs.client_secret || steps.state.outputs.client_secret }} SLACK_SIGNING_SECRET: ${{ steps.slack.outputs.signing_secret || steps.state.outputs.signing_secret }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_WEBHOOK_SECRET_TOKEN: ${{ secrets.TELEGRAM_WEBHOOK_SECRET_TOKEN }} TWITTER_OAUTH_CLIENT_ID: ${{ secrets.TWITTER_OAUTH_CLIENT_ID }} TWITTER_OAUTH_CLIENT_SECRET: ${{ secrets.TWITTER_OAUTH_CLIENT_SECRET }} diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 077c08d..7478c66 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -15,6 +15,8 @@ jobs: permissions: contents: read uses: ./.github/workflows/check.yml + with: + skip_workers: true deploy: name: Deploy @@ -58,6 +60,8 @@ jobs: SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET + TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET_TOKEN TWITTER_ACCESS_TOKEN TWITTER_ACCESS_TOKEN_SECRET TWITTER_BEARER_TOKEN @@ -73,6 +77,8 @@ jobs: SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }} SLACK_CLIENT_SECRET: ${{ secrets.SLACK_CLIENT_SECRET }} SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_WEBHOOK_SECRET_TOKEN: ${{ secrets.TELEGRAM_WEBHOOK_SECRET_TOKEN }} TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2292033..8513ee5 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -18,6 +18,8 @@ jobs: check: name: Check uses: ./.github/workflows/check.yml + with: + skip_workers: true preview: name: Preview @@ -31,6 +33,7 @@ jobs: with: pr_number: ${{ github.event.pull_request.number }} sha: ${{ github.event.pull_request.head.sha }} + telegram_bot_username: ${{ vars.TELEGRAM_BOT_USERNAME || 'ggtipbot' }} twitter_bot_handle: ${{ vars.TWITTER_BOT_HANDLE || 'tipbotgg' }} secrets: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -41,5 +44,7 @@ jobs: SLACK_CONFIG_ACCESS_TOKEN: ${{ secrets.SLACK_CONFIG_ACCESS_TOKEN }} SLACK_CONFIG_REFRESH_TOKEN: ${{ secrets.SLACK_CONFIG_REFRESH_TOKEN }} SLACK_GH_TOKEN: ${{ secrets.SLACK_GH_TOKEN }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_WEBHOOK_SECRET_TOKEN: ${{ secrets.TELEGRAM_WEBHOOK_SECRET_TOKEN }} TWITTER_OAUTH_CLIENT_ID: ${{ secrets.TWITTER_OAUTH_CLIENT_ID }} TWITTER_OAUTH_CLIENT_SECRET: ${{ secrets.TWITTER_OAUTH_CLIENT_SECRET }} diff --git a/db/migrations/0020_telegram_provider.sql b/db/migrations/0020_telegram_provider.sql new file mode 100644 index 0000000..be2830d --- /dev/null +++ b/db/migrations/0020_telegram_provider.sql @@ -0,0 +1,292 @@ +-- Allow Telegram group workspaces and tips alongside Slack provider rows. +PRAGMA foreign_keys = OFF; + +ALTER TABLE "workspace" RENAME TO "workspace_old"; +ALTER TABLE "provider_identity" RENAME TO "provider_identity_old"; +ALTER TABLE "member" RENAME TO "member_old"; +ALTER TABLE "tip_batch" RENAME TO "tip_batch_old"; +ALTER TABLE "account_link_token" RENAME TO "account_link_token_old"; +ALTER TABLE "tip" RENAME TO "tip_old"; +ALTER TABLE "reaction_tip_thread" RENAME TO "reaction_tip_thread_old"; +ALTER TABLE "reaction_tip_config" RENAME TO "reaction_tip_config_old"; +ALTER TABLE "reaction_tip" RENAME TO "reaction_tip_old"; +ALTER TABLE "tip_receipt_message" RENAME TO "tip_receipt_message_old"; +ALTER TABLE "receipt_boost_thread" RENAME TO "receipt_boost_thread_old"; +ALTER TABLE "pending_tip" RENAME TO "pending_tip_old"; + +CREATE TABLE "workspace" ( + "id" TEXT PRIMARY KEY NOT NULL, + "provider" TEXT NOT NULL DEFAULT 'slack' CHECK ("provider" IN ('slack', 'telegram')), + "provider_id" TEXT NOT NULL, + "name" TEXT, + "default_amount" INTEGER NOT NULL DEFAULT 1000 CHECK ("default_amount" > 0), + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +, "default_token_address" TEXT, "chain_id" INTEGER NOT NULL DEFAULT 4217, "installed_at" TEXT, "uninstalled_at" TEXT); + +CREATE TABLE "provider_identity" ( + "id" TEXT PRIMARY KEY NOT NULL, + "provider" TEXT NOT NULL CHECK ("provider" IN ('slack', 'telegram')), + "provider_workspace_id" TEXT, + "provider_user_id" TEXT NOT NULL, + "provider_global_user_id" TEXT, + "account_id" TEXT REFERENCES "account" ("id") ON DELETE SET NULL, + "display_name" TEXT, + "real_name" TEXT, + "metadata" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "member" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "provider_identity_id" TEXT NOT NULL REFERENCES "provider_identity" ("id"), + "provider_user_id" TEXT NOT NULL, + "login" TEXT, + "name" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "tip_batch" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "idempotency_key" TEXT NOT NULL UNIQUE, + "sender_member_id" TEXT NOT NULL REFERENCES "member" ("id"), + "provider" TEXT NOT NULL DEFAULT 'slack' CHECK ("provider" IN ('slack', 'telegram')), + "provider_id" TEXT NOT NULL, + "provider_channel_id" TEXT NOT NULL, + "provider_thread_id" TEXT, + "source" TEXT NOT NULL DEFAULT 'command' CHECK ("source" IN ('command', 'mention', 'reaction', 'migration')), + "amount_each" INTEGER NOT NULL CHECK ("amount_each" > 0), + "total_amount" INTEGER NOT NULL CHECK ("total_amount" > 0), + "recipient_count" INTEGER NOT NULL CHECK ("recipient_count" > 0), + "token_address" TEXT NOT NULL, + "memo" TEXT, + "status" TEXT NOT NULL CHECK ("status" IN ('pending', 'needs_confirmation', 'submitting', 'confirmed', 'failed', 'canceled')), + "transaction_hash" TEXT UNIQUE, + "failure_reason" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ("status" = 'confirmed' OR "transaction_hash" IS NULL), + CHECK ("status" = 'failed' OR "failure_reason" IS NULL) +); + +CREATE TABLE "account_link_token" ( + "id" TEXT PRIMARY KEY NOT NULL, + "account_id" TEXT REFERENCES "account" ("id"), + "member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, + "token_hash" TEXT NOT NULL UNIQUE, + "access_key_address" TEXT NOT NULL, + "access_key_public_key" TEXT NOT NULL, + "access_key_ciphertext" TEXT NOT NULL, + "access_key_expires_at" TEXT NOT NULL, + "access_key_authorization" TEXT, + "expires_at" TEXT NOT NULL, + "used_at" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "provider_channel_id" TEXT, "channel_provider_id" TEXT, + CHECK ("used_at" IS NULL OR ("account_id" IS NOT NULL AND "access_key_authorization" IS NOT NULL)) +); + +CREATE TABLE "tip" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "idempotency_key" TEXT NOT NULL UNIQUE, + "sender_id" TEXT NOT NULL REFERENCES "account" ("id"), + "recipient_id" TEXT NOT NULL REFERENCES "account" ("id"), + "sender_member_id" TEXT NOT NULL REFERENCES "member" ("id"), + "recipient_member_id" TEXT NOT NULL REFERENCES "member" ("id"), + "amount" INTEGER NOT NULL CHECK ("amount" > 0), + "token_address" TEXT NOT NULL, + "memo" TEXT, + "confirmed_at" TEXT, + "failed_at" TEXT, + "failure_reason" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "chain_id" INTEGER NOT NULL DEFAULT 4217, + "sponsorship_memo" TEXT, + "access_key_id" TEXT REFERENCES "access_key" ("id") ON DELETE SET NULL, + "batch_id" TEXT REFERENCES "tip_batch" ("id") ON DELETE SET NULL, + "transfer_log_index" INTEGER, + CHECK ("confirmed_at" IS NULL OR "failed_at" IS NULL), + CHECK ("failed_at" IS NOT NULL OR "failure_reason" IS NULL) +); + +CREATE TABLE "reaction_tip_thread" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "channel_id" TEXT NOT NULL, + "message_ts" TEXT NOT NULL, + "reply_ts" TEXT NOT NULL, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "reaction_tip_config" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "emoji" TEXT NOT NULL CHECK ("emoji" <> ''), + "amount" INTEGER NOT NULL CHECK ("amount" > 0), + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "reaction_tip" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "channel_id" TEXT NOT NULL, + "message_ts" TEXT NOT NULL, + "thread_ts" TEXT NOT NULL, + "reaction" TEXT NOT NULL CHECK ("reaction" <> ''), + "idempotency_key" TEXT NOT NULL UNIQUE, + "sender_member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, + "recipient_member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, + "tip_id" TEXT REFERENCES "tip" ("id") ON DELETE SET NULL, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "tip_receipt_message" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "tip_batch_id" TEXT NOT NULL REFERENCES "tip_batch" ("id") ON DELETE CASCADE, + "channel_id" TEXT NOT NULL, + "message_ts" TEXT NOT NULL, + "thread_ts" TEXT NOT NULL, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "receipt_boost_thread" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "channel_id" TEXT NOT NULL, + "thread_ts" TEXT NOT NULL, + "reply_ts" TEXT NOT NULL, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "pending_tip" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "idempotency_key" TEXT NOT NULL UNIQUE, + "sender_id" TEXT NOT NULL REFERENCES "account" ("id"), + "sender_member_id" TEXT NOT NULL REFERENCES "member" ("id"), + "sender_provider_user_id" TEXT NOT NULL, + "access_key_id" TEXT REFERENCES "access_key" ("id") ON DELETE SET NULL, + "recipient_member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, + "recipient_provider_user_id" TEXT NOT NULL, + "chain_id" INTEGER NOT NULL, + "amount" INTEGER NOT NULL CHECK ("amount" > 0), + "token_address" TEXT NOT NULL, + "memo" TEXT, + "provider" TEXT NOT NULL DEFAULT 'slack' CHECK ("provider" IN ('slack', 'telegram')), + "provider_id" TEXT NOT NULL, + "provider_channel_id" TEXT NOT NULL, + "provider_thread_id" TEXT, + "provider_message_ts" TEXT, + "source" TEXT NOT NULL CHECK ("source" IN ('command', 'mention', 'reaction')), + "status" TEXT NOT NULL CHECK ("status" IN ('pending', 'sending', 'sent', 'failed', 'expired')), + "tip_id" TEXT REFERENCES "tip" ("id") ON DELETE SET NULL, + "failure_reason" TEXT, + "expires_at" TEXT NOT NULL, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ("status" = 'sent' OR "tip_id" IS NULL), + CHECK ("status" IN ('failed', 'expired') OR "failure_reason" IS NULL) +); + +INSERT INTO "workspace" ("id", "provider", "provider_id", "name", "default_amount", "created_at", "updated_at", "default_token_address", "chain_id", "installed_at", "uninstalled_at") SELECT "id", "provider", "provider_id", "name", "default_amount", "created_at", "updated_at", "default_token_address", "chain_id", "installed_at", "uninstalled_at" FROM "workspace_old"; +INSERT INTO "provider_identity" ("id", "provider", "provider_workspace_id", "provider_user_id", "provider_global_user_id", "account_id", "display_name", "real_name", "metadata", "created_at", "updated_at") SELECT "id", "provider", "provider_workspace_id", "provider_user_id", "provider_global_user_id", "account_id", "display_name", "real_name", "metadata", "created_at", "updated_at" FROM "provider_identity_old"; +INSERT INTO "member" ("id", "workspace_id", "provider_identity_id", "provider_user_id", "login", "name", "created_at", "updated_at") SELECT "id", "workspace_id", "provider_identity_id", "provider_user_id", "login", "name", "created_at", "updated_at" FROM "member_old"; +INSERT INTO "tip_batch" ("id", "workspace_id", "idempotency_key", "sender_member_id", "provider", "provider_id", "provider_channel_id", "provider_thread_id", "source", "amount_each", "total_amount", "recipient_count", "token_address", "memo", "status", "transaction_hash", "failure_reason", "created_at", "updated_at") SELECT "id", "workspace_id", "idempotency_key", "sender_member_id", "provider", "provider_id", "provider_channel_id", "provider_thread_id", "source", "amount_each", "total_amount", "recipient_count", "token_address", "memo", "status", "transaction_hash", "failure_reason", "created_at", "updated_at" FROM "tip_batch_old"; +INSERT INTO "account_link_token" ("id", "account_id", "member_id", "token_hash", "access_key_address", "access_key_public_key", "access_key_ciphertext", "access_key_expires_at", "access_key_authorization", "expires_at", "used_at", "created_at", "provider_channel_id", "channel_provider_id") SELECT "id", "account_id", "member_id", "token_hash", "access_key_address", "access_key_public_key", "access_key_ciphertext", "access_key_expires_at", "access_key_authorization", "expires_at", "used_at", "created_at", "provider_channel_id", "channel_provider_id" FROM "account_link_token_old"; +INSERT INTO "tip" ("id", "workspace_id", "idempotency_key", "sender_id", "recipient_id", "sender_member_id", "recipient_member_id", "amount", "token_address", "memo", "confirmed_at", "failed_at", "failure_reason", "created_at", "updated_at", "chain_id", "sponsorship_memo", "access_key_id", "batch_id", "transfer_log_index") SELECT "id", "workspace_id", "idempotency_key", "sender_id", "recipient_id", "sender_member_id", "recipient_member_id", "amount", "token_address", "memo", "confirmed_at", "failed_at", "failure_reason", "created_at", "updated_at", "chain_id", "sponsorship_memo", "access_key_id", "batch_id", "transfer_log_index" FROM "tip_old"; +INSERT INTO "reaction_tip_thread" ("id", "workspace_id", "channel_id", "message_ts", "reply_ts", "created_at", "updated_at") SELECT "id", "workspace_id", "channel_id", "message_ts", "reply_ts", "created_at", "updated_at" FROM "reaction_tip_thread_old"; +INSERT INTO "reaction_tip_config" ("id", "workspace_id", "emoji", "amount", "created_at", "updated_at") SELECT "id", "workspace_id", "emoji", "amount", "created_at", "updated_at" FROM "reaction_tip_config_old"; +INSERT INTO "reaction_tip" ("id", "workspace_id", "channel_id", "message_ts", "thread_ts", "reaction", "idempotency_key", "sender_member_id", "recipient_member_id", "tip_id", "created_at", "updated_at") SELECT "id", "workspace_id", "channel_id", "message_ts", "thread_ts", "reaction", "idempotency_key", "sender_member_id", "recipient_member_id", "tip_id", "created_at", "updated_at" FROM "reaction_tip_old"; +INSERT INTO "tip_receipt_message" ("id", "workspace_id", "tip_batch_id", "channel_id", "message_ts", "thread_ts", "created_at", "updated_at") SELECT "id", "workspace_id", "tip_batch_id", "channel_id", "message_ts", "thread_ts", "created_at", "updated_at" FROM "tip_receipt_message_old"; +INSERT INTO "receipt_boost_thread" ("id", "workspace_id", "channel_id", "thread_ts", "reply_ts", "created_at", "updated_at") SELECT "id", "workspace_id", "channel_id", "thread_ts", "reply_ts", "created_at", "updated_at" FROM "receipt_boost_thread_old"; +INSERT INTO "pending_tip" ("id", "workspace_id", "idempotency_key", "sender_id", "sender_member_id", "sender_provider_user_id", "access_key_id", "recipient_member_id", "recipient_provider_user_id", "chain_id", "amount", "token_address", "memo", "provider", "provider_id", "provider_channel_id", "provider_thread_id", "provider_message_ts", "source", "status", "tip_id", "failure_reason", "expires_at", "created_at", "updated_at") SELECT "id", "workspace_id", "idempotency_key", "sender_id", "sender_member_id", "sender_provider_user_id", "access_key_id", "recipient_member_id", "recipient_provider_user_id", "chain_id", "amount", "token_address", "memo", "provider", "provider_id", "provider_channel_id", "provider_thread_id", "provider_message_ts", "source", "status", "tip_id", "failure_reason", "expires_at", "created_at", "updated_at" FROM "pending_tip_old"; + +DROP TABLE "pending_tip_old"; +DROP TABLE "receipt_boost_thread_old"; +DROP TABLE "tip_receipt_message_old"; +DROP TABLE "reaction_tip_old"; +DROP TABLE "reaction_tip_config_old"; +DROP TABLE "reaction_tip_thread_old"; +DROP TABLE "tip_old"; +DROP TABLE "account_link_token_old"; +DROP TABLE "tip_batch_old"; +DROP TABLE "member_old"; +DROP TABLE "provider_identity_old"; +DROP TABLE "workspace_old"; + +CREATE UNIQUE INDEX "workspace_provider_idx" ON "workspace" ("provider", "provider_id"); +CREATE UNIQUE INDEX "provider_identity_provider_user_idx" ON "provider_identity" ( + "provider", + coalesce("provider_workspace_id", ''), + "provider_user_id" +); +CREATE INDEX "provider_identity_account_idx" ON "provider_identity" ("account_id"); +CREATE INDEX "provider_identity_global_user_idx" ON "provider_identity" ( + "provider", + "provider_global_user_id" +) WHERE "provider_global_user_id" IS NOT NULL; +CREATE UNIQUE INDEX "member_workspace_provider_user_idx" ON "member" ("workspace_id", "provider_user_id"); +CREATE INDEX "member_provider_identity_idx" ON "member" ("provider_identity_id"); +CREATE INDEX "tip_batch_workspace_created_idx" ON "tip_batch" ("workspace_id", "created_at"); +CREATE INDEX "tip_batch_sender_created_idx" ON "tip_batch" ("sender_member_id", "created_at"); +CREATE INDEX "tip_batch_status_idx" ON "tip_batch" ("status", "updated_at"); +CREATE INDEX "tip_batch_transaction_hash_idx" ON "tip_batch" ("transaction_hash") WHERE "transaction_hash" IS NOT NULL; +CREATE INDEX "account_link_token_account_idx" ON "account_link_token" ("account_id"); +CREATE INDEX "account_link_token_member_idx" ON "account_link_token" ("member_id"); +CREATE INDEX "tip_workspace_created_idx" ON "tip" ("workspace_id", "created_at"); +CREATE INDEX "tip_sender_created_idx" ON "tip" ("sender_id", "created_at"); +CREATE INDEX "tip_recipient_created_idx" ON "tip" ("recipient_id", "created_at"); +CREATE INDEX "tip_batch_tip_idx" ON "tip" ("batch_id"); +CREATE UNIQUE INDEX "reaction_tip_thread_message_idx" ON "reaction_tip_thread" ( + "workspace_id", + "channel_id", + "message_ts" +); +CREATE UNIQUE INDEX "reaction_tip_config_workspace_emoji_idx" ON "reaction_tip_config" ( + "workspace_id", + "emoji" +); +CREATE UNIQUE INDEX "reaction_tip_message_sender_idx" ON "reaction_tip" ( + "workspace_id", + "channel_id", + "message_ts", + "reaction", + "sender_member_id" +); +CREATE INDEX "reaction_tip_message_idx" ON "reaction_tip" ( + "workspace_id", + "channel_id", + "message_ts", + "reaction", + "created_at" +); +CREATE UNIQUE INDEX "reaction_tip_tip_idx" ON "reaction_tip" ("tip_id") WHERE "tip_id" IS NOT NULL; +CREATE UNIQUE INDEX "tip_receipt_message_message_idx" ON "tip_receipt_message" ( + "workspace_id", + "channel_id", + "message_ts" +); +CREATE INDEX "tip_receipt_message_tip_batch_idx" ON "tip_receipt_message" ("tip_batch_id"); +CREATE UNIQUE INDEX "receipt_boost_thread_thread_idx" ON "receipt_boost_thread" ( + "workspace_id", + "channel_id", + "thread_ts" +); +CREATE INDEX "pending_tip_recipient_status_idx" ON "pending_tip" ("recipient_member_id", "status"); +CREATE INDEX "pending_tip_sender_status_idx" ON "pending_tip" ("sender_id", "status"); +CREATE INDEX "pending_tip_workspace_created_idx" ON "pending_tip" ("workspace_id", "created_at"); +CREATE INDEX "pending_tip_status_expires_idx" ON "pending_tip" ("status", "expires_at"); + +PRAGMA foreign_keys = ON; diff --git a/db/schemas.gen.ts b/db/schemas.gen.ts index 7e88f7a..96d3597 100644 --- a/db/schemas.gen.ts +++ b/db/schemas.gen.ts @@ -62,7 +62,7 @@ export const pending_tip = z.object({ id: z.string(), idempotency_key: z.string(), memo: z.string().nullable(), - provider: z.literal('slack'), + provider: z.enum(['slack', 'telegram']), provider_channel_id: z.string(), provider_id: z.string(), provider_message_ts: z.string().nullable(), @@ -86,7 +86,7 @@ export const provider_identity = z.object({ display_name: z.string().nullable(), id: z.string(), metadata: z.string().nullable(), - provider: z.literal('slack'), + provider: z.enum(['slack', 'telegram']), provider_global_user_id: z.string().nullable(), provider_user_id: z.string(), provider_workspace_id: z.string().nullable(), @@ -201,7 +201,7 @@ export const tip_batch = z.object({ id: z.string(), idempotency_key: z.string(), memo: z.string().nullable(), - provider: z.literal('slack'), + provider: z.enum(['slack', 'telegram']), provider_channel_id: z.string(), provider_id: z.string(), provider_thread_id: z.string().nullable(), @@ -242,7 +242,7 @@ export const workspace = z.object({ id: z.string(), installed_at: z.string().nullable(), name: z.string().nullable(), - provider: z.literal('slack'), + provider: z.enum(['slack', 'telegram']), provider_id: z.string(), uninstalled_at: z.string().nullable(), updated_at: z.string(), diff --git a/db/types.gen.ts b/db/types.gen.ts index decaac8..5eb6141 100644 --- a/db/types.gen.ts +++ b/db/types.gen.ts @@ -81,7 +81,7 @@ type pending_tip = { id: string idempotency_key: string memo: string | null - provider: k.Generated<'slack'> + provider: k.Generated<'slack' | 'telegram'> provider_channel_id: string provider_id: string provider_message_ts: string | null @@ -105,7 +105,7 @@ type provider_identity = { display_name: string | null id: string metadata: string | null - provider: 'slack' + provider: 'slack' | 'telegram' provider_global_user_id: string | null provider_user_id: string provider_workspace_id: string | null @@ -220,7 +220,7 @@ type tip_batch = { id: string idempotency_key: string memo: string | null - provider: k.Generated<'slack'> + provider: k.Generated<'slack' | 'telegram'> provider_channel_id: string provider_id: string provider_thread_id: string | null @@ -254,7 +254,7 @@ type workspace = { id: string installed_at: string | null name: string | null - provider: k.Generated<'slack'> + provider: k.Generated<'slack' | 'telegram'> provider_id: string uninstalled_at: string | null updated_at: k.Generated diff --git a/package.json b/package.json index a7aa0e1..981707c 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,14 @@ "prepare": "vp config && pnpm gen:types", "preview": "vp build && vp preview", "slack:app": "node --experimental-strip-types scripts/slack.ts", + "telegram:app": "node --experimental-strip-types scripts/telegram.ts", "test": "vp test", "test:e2e": "playwright test --config test/e2e.config.ts" }, "dependencies": { "@base-ui/react": "1.4.1", "@chat-adapter/slack": "4.27.0", + "@chat-adapter/telegram": "4.27.0", "@emoji-mart/data": "1.2.1", "@hono/zod-validator": "^0.8.0", "@tanstack/react-query": "^5.100.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a25d7a..a2e3272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@chat-adapter/slack': specifier: 4.27.0 version: 4.27.0(patch_hash=f129c43965d79b15a626b2f5e493aee5ae9b5638ff12257bd4cf6cecb048d1dd) + '@chat-adapter/telegram': + specifier: 4.27.0 + version: 4.27.0 '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -322,6 +325,9 @@ packages: '@chat-adapter/slack@4.27.0': resolution: {integrity: sha512-Hx9tahCY/gA6EDydGdXcaz5r28sJOAAwnUP/j48Wv9U4LylsT4hXtLBoSmxb8gRvom2FypxD9Ry/SwzPnwnpBw==} + '@chat-adapter/telegram@4.27.0': + resolution: {integrity: sha512-taRZAPw5Sw7G4BtsGCIV5QqaXrKUlmLWoX04NZxGUC0Gn7MuYPwh7P7wMk04LvQ2lZfhjVCr22UlvayloUPSfg==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -4260,6 +4266,13 @@ snapshots: - supports-color - utf-8-validate + '@chat-adapter/telegram@4.27.0': + dependencies: + '@chat-adapter/shared': 4.27.0 + chat: 4.27.0 + transitivePeerDependencies: + - supports-color + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260507.1)': diff --git a/scripts/telegram.ts b/scripts/telegram.ts new file mode 100644 index 0000000..7d309f7 --- /dev/null +++ b/scripts/telegram.ts @@ -0,0 +1,44 @@ +const args = process.argv.slice(2) +const command = args[0] +const apiUrl = process.env.TELEGRAM_API_URL ?? 'https://api.telegram.org' +const token = process.env.TELEGRAM_BOT_TOKEN +const secretToken = process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN +const host = args[1] ?? process.env.HOST + +if (!token) usage('TELEGRAM_BOT_TOKEN is required.') +if (!secretToken) usage('TELEGRAM_WEBHOOK_SECRET_TOKEN is required.') + +if (command === 'set') { + if (!host) usage('host is required.') + const response = await fetch(`${apiUrl}/bot${token}/setWebhook`, { + body: JSON.stringify({ + allowed_updates: ['message', 'callback_query'], + secret_token: secretToken, + url: `https://${host}/api/chat/telegram`, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + const json = await response.json() + console.log(JSON.stringify(json, null, 2)) + if (!response.ok) process.exit(1) +} else if (command === 'info') { + const response = await fetch(`${apiUrl}/bot${token}/getWebhookInfo`) + console.log(JSON.stringify(await response.json(), null, 2)) + if (!response.ok) process.exit(1) +} else usage() + +function usage(message?: string): never { + if (message) console.error(message) + console.error(` +Usage: + pnpm telegram:app set + pnpm telegram:app info + +Environment: + TELEGRAM_API_URL Defaults to https://api.telegram.org + TELEGRAM_BOT_TOKEN Bot token from BotFather + TELEGRAM_WEBHOOK_SECRET_TOKEN Secret token for webhook verification +`) + process.exit(1) +} diff --git a/src/api.ts b/src/api.ts index 5d25727..8f3df55 100644 --- a/src/api.ts +++ b/src/api.ts @@ -672,7 +672,12 @@ export const api = new Hono<{ kind: data.payload.kind, memo: data.payload.memo, ok: true as const, - providerLabel: data.payload.providerId === Twitter.twitterProviderId ? 'X' : 'Slack', + providerLabel: + data.payload.providerId === Twitter.twitterProviderId + ? 'X' + : data.payload.provider === 'telegram' + ? 'Telegram' + : 'Slack', recipientAvatarUrl, recipientProviderLabel, recipientProviderUserId: data.payload.recipientProviderUserId, @@ -737,6 +742,30 @@ export const api = new Hono<{ console.error('Failed to post Twitter receipt after confirmation:', error) }), ) + else if (result.status === 'sent' && data.payload.provider === 'telegram') + c.executionCtx.waitUntil( + (async () => { + await Chat.getChat().initialize() + const amount = result.isDefaultToken + ? formatCurrencyAmount(result.amount, result.tokenCurrency) + : formatTipAmount(result.amount, result.tokenCurrency, result.tokenSymbol) + const text = + 'recipients' in result + ? `${result.senderProviderUserId} ${result.memo ? 'sent' : 'tipped'} ${result.recipients.map((recipient) => recipient.recipientProviderUserId).join(' ')} ${amount} each${result.memo ? ` for ${result.memo}` : ''}` + : `${result.senderProviderUserId} ${result.memo ? 'sent' : 'tipped'} ${result.recipientProviderUserId} ${amount}${result.memo ? ` for ${result.memo}` : ''}` + await Chat.getChat() + .channel( + data.payload.providerThreadId + ? `${data.payload.providerChannelId}:${data.payload.providerThreadId}` + : data.payload.providerChannelId, + ) + .post( + `${text} · Receipt: ${Tempo.formatTxLink(result.chainId, result.transactionHash)}`, + ) + })().catch((error) => { + console.error('Failed to post Telegram receipt after confirmation:', error) + }), + ) else if ( result.status === 'sent' && Chat.isReactionTipIdempotencyKey(data.payload.idempotencyKey) @@ -1044,6 +1073,17 @@ export const api = new Hono<{ ) } }) + .post('/api/chat/telegram', async (c) => { + if (c.req.header('x-telegram-bot-api-secret-token') !== c.env.TELEGRAM_WEBHOOK_SECRET_TOKEN) + return c.json({ code: 'invalid_signature' as const, ok: false as const }, 401) + const update = await c.req.json() + c.executionCtx.waitUntil( + Chat.handleTelegramUpdate(update).catch((error) => { + console.error('Telegram webhook failed:', error) + }), + ) + return c.json({ ok: true as const }) + }) .post('/api/chat/slack', async (c) => { const request = c.req.raw const body = await request.text() diff --git a/src/api.workers.test.ts b/src/api.workers.test.ts index fea8af2..03c5efb 100644 --- a/src/api.workers.test.ts +++ b/src/api.workers.test.ts @@ -1259,6 +1259,114 @@ describe('/api/link/twitter', () => { }) }) +describe('/api/chat/telegram', () => { + test('group mention tip resolves known Telegram username', async () => { + const workspace = await factory.workspace.insert({ + provider: 'telegram', + provider_id: '-100123', + }) + const identity = await factory.provider_identity.insert({ + display_name: '@alice', + provider: 'telegram', + provider_user_id: '200', + provider_workspace_id: '-100123', + }) + await factory.member.insert({ + provider_identity_id: identity.id, + provider_user_id: '200', + workspace_id: workspace.id, + }) + const handleTipRequest = vi.spyOn(Tip, 'handleTipRequest').mockResolvedValue({ + amount: '$5.00', + chainId: Tempo.chainLookup.localnet, + feePayer: 'sponsor', + isDefaultToken: true, + memo: 'coffee', + ok: true, + recipientProviderUserId: '200', + senderProviderUserId: '100', + status: 'sent', + tokenCurrency: 'USD', + tokenSymbol: 'PathUSD', + transactionHash: `0x${'1'.repeat(64)}`, + }) + const telegramPosts: unknown[] = [] + server.use( + mswHttp.post('https://api.telegram.org/bot000000:test/getMe', () => + HttpResponse.json({ + ok: true, + result: { first_name: 'Tipbot', id: 1, is_bot: true, username: 'ggtipbot' }, + }), + ), + mswHttp.post('https://api.telegram.org/bot000000:test/getWebhookInfo', () => + HttpResponse.json({ + ok: true, + result: { + has_custom_certificate: false, + pending_update_count: 0, + url: 'https://tip.bot/api/chat/telegram', + }, + }), + ), + mswHttp.post('https://api.telegram.org/bot000000:test/sendMessage', async ({ request }) => { + telegramPosts.push(await request.json()) + return HttpResponse.json({ + ok: true, + result: { + chat: { id: -100123, type: 'supergroup' }, + date: Math.floor(Date.now() / 1000), + message_id: 2, + text: 'ok', + }, + }) + }), + ) + + const response = await api.fetch( + new Request('https://tip.bot/api/chat/telegram', { + body: JSON.stringify({ + message: { + chat: { id: -100123, title: 'Tempo', type: 'supergroup' }, + date: Math.floor(Date.now() / 1000), + entities: [ + { length: 9, offset: 0, type: 'mention' }, + { length: 6, offset: 10, type: 'mention' }, + ], + from: { first_name: 'Bob', id: 100, is_bot: false, username: 'bob' }, + message_id: 1, + text: '@ggtipbot @alice 5 for coffee', + }, + update_id: 1, + }), + headers: { + 'content-type': 'application/json', + 'x-telegram-bot-api-secret-token': env.TELEGRAM_WEBHOOK_SECRET_TOKEN, + }, + method: 'POST', + }), + env, + executionCtx, + ) + await Promise.all(waitUntil) + + expect(response.status).toBe(200) + expect(handleTipRequest).toHaveBeenCalledWith( + env, + expect.objectContaining({ + amount: 5_000_000, + memo: 'coffee', + provider: 'telegram', + providerChannelId: 'telegram:-100123', + providerId: '-100123', + recipientProviderLabel: '@alice', + recipientProviderUserId: '200', + senderProviderUserId: '100', + }), + ) + expect(telegramPosts).toHaveLength(1) + }) +}) + describe('/api/chat/slack', () => { test('Slack URL verification reaches the Worker API route', async () => { const body = JSON.stringify({ diff --git a/src/chat.ts b/src/chat.ts index 3ca6015..d0f0634 100644 --- a/src/chat.ts +++ b/src/chat.ts @@ -7,6 +7,7 @@ import * as Emoji from '#/lib/emoji.ts' import { formatAmount, formatCurrencyAmount, formatTipAmount } from '#/lib/format.ts' import * as Nanoid from '#/lib/nanoid.ts' import * as Slack from '#/lib/slack.ts' +import * as Telegram from '#/lib/telegram.ts' import * as Tempo from '#/lib/tempo.ts' import * as Tip from '#/lib/tip.ts' import { createCloudflareState } from '#/vendor/chatStateCloudflareDO.ts' @@ -23,7 +24,7 @@ let bot: chat.Chat | null = null export function getChat() { if (bot) return bot bot = new chat.Chat({ - adapters: { slack: getSlack() }, + adapters: { slack: getSlack(), telegram: getTelegram() }, state: createCloudflareState({ name: 'tipbot', namespace: env.CHAT_STATE, @@ -34,9 +35,12 @@ export function getChat() { userName: 'tipbot', }) bot.onAction(async (event) => { - if (event.adapter !== getSlack()) throw new Error('Provider not implemented yet.') const action = z.safeParse(z.enum(actionNames), event.actionId) if (!action.success) return + if (event.adapter !== getSlack() && event.adapter !== getTelegram()) + throw new Error('Provider not implemented yet.') + if (event.adapter === getTelegram() && !['confirm_cancel', 'confirm_tip'].includes(action.data)) + return await actions[action.data](event) }) bot.onModalSubmit(async (event) => { @@ -46,6 +50,14 @@ export function getChat() { return await modalSubmits[modalSubmit.data](event) }) bot.onNewMention(async (thread, message) => { + if (thread.adapter === getTelegram()) { + await handleTelegramMessageRaw( + thread.channel, + message.author, + z.parse(Telegram.messageSchema, message.raw), + ) + return + } if (thread.adapter !== getSlack()) throw new Error('Provider not implemented yet.') const raw = z.parse( @@ -378,6 +390,8 @@ export function getSlack() { return slack } +export const getTelegram = Telegram.getAdapter + const actions = { async config_edit(event) { const raw = z.parse( @@ -521,7 +535,7 @@ const actions = { amount: pending.amount, idempotencyKey: pending.idempotencyKey, memo: pending.memo, - provider: 'slack', + provider: pending.providerType ?? 'slack', providerChannelId: pending.providerChannelId, providerId: pending.providerId, providerThreadId: pending.providerThreadId, @@ -1296,7 +1310,7 @@ const handlers = { event.user, `Payment not sent. Memo must be at most 32 bytes; shorten the text after \`for\`.${suggestion ? ` Try: \`${suggestion}\`.` : ''}`, ) - if (options.threadTs) + if (options.threadTs && ctx.provider.type === 'slack') await Slack.setAssistantThreadStatus({ apiUrl: env.SLACK_API_URL, channelId: event.channel.id, @@ -1312,6 +1326,7 @@ const handlers = { const shouldResolvePlan = parsed.recipients.length !== 1 || Boolean(parsed.usergroups?.length) || + ctx.provider.type === 'telegram' || (await (async () => { // Single-recipient Slack Connect tips still need workspace-safe recipient resolution. const installation = await getSlack().getInstallation( @@ -1333,10 +1348,14 @@ const handlers = { previewRequired: false, recipients: parsed.recipients, skippedRecipients: [], + usergroupId: undefined, + usergroupLabel: undefined, } - : await resolveSlackTipPlan(event, ctx, parsed, { - amountEach: parsed.amount ?? workspace?.default_amount, - }) + : ctx.provider.type === 'telegram' + ? await resolveTelegramTipPlan(ctx, parsed) + : await resolveSlackTipPlan(event, ctx, parsed, { + amountEach: parsed.amount ?? workspace?.default_amount, + }) if (!plan.ok) { await postPrivateReply(event, event.user, plan.message, { providerId: ctx.channelProviderId ?? ctx.provider.id, @@ -1369,7 +1388,7 @@ const handlers = { return } - if (options.threadTs) + if (options.threadTs && ctx.provider.type === 'slack') void Slack.setAssistantThreadStatus({ apiUrl: env.SLACK_API_URL, channelId: event.channel.id, @@ -1459,7 +1478,7 @@ const handlers = { : undefined, options.threadTs, ) - if (result.memo && options.threadTs) + if (result.memo && options.threadTs && ctx.provider.type === 'slack') await postSlackMemoReply(event, ctx, result.memo, options.threadTs) } else if (result.ok) await postSlackReceiptMessage( @@ -1497,6 +1516,10 @@ const handlers = { return result.message ?? 'Payment failed.' })() if (result.code === 'insufficient_funds') { + if (ctx.provider.type === 'telegram') { + await postPrivateReply(event, event.user, message) + return + } await postSlackInsufficientFunds( event, ctx, @@ -1525,7 +1548,7 @@ const handlers = { } finally { // Clear Slack's assistant thread status after this request finishes or hands off to // confirmation. - if (options.threadTs) + if (options.threadTs && ctx.provider.type === 'slack') await Slack.setAssistantThreadStatus({ apiUrl: env.SLACK_API_URL, channelId: event.channel.id, @@ -1585,7 +1608,7 @@ type HandlerContext = { threadTs?: string } -type ProviderContext = { id: string; type: 'slack' } +type ProviderContext = { id: string; type: 'slack' | 'telegram' } type TipEvent = { channel: chat.Channel @@ -1613,6 +1636,7 @@ type PendingSlackTip = { memo: string | null providerChannelId: string providerId: string + providerType?: ProviderContext['type'] providerThreadId?: string recipients: Tip.TipRecipientInput[] senderProviderUserId: string @@ -1639,6 +1663,35 @@ export function isReceiptBoostIdempotencyKey(value: string) { return value.startsWith(receiptBoostIdempotencyPrefix) } +async function resolveTelegramTipPlan( + ctx: HandlerContext, + parsed: ParsedTipBatch, +): Promise< + | { + ok: true + previewRequired: false + recipients: Tip.TipRecipientInput[] + skippedRecipients: Tip.TipSkippedRecipient[] + usergroupId?: undefined + usergroupLabel?: undefined + } + | { message: string; ok: false } +> { + if (parsed.usergroups?.length) + return { + message: 'Payment not sent. Telegram group mentions are not supported yet.', + ok: false, + } + if (parsed.recipients.some((recipient) => recipient.recipientProviderUserId === ctx.provider.id)) + return { message: 'Payment not sent. Cannot send a payment to this group.', ok: false } + return { + ok: true, + previewRequired: false, + recipients: parsed.recipients, + skippedRecipients: [], + } +} + async function resolveSlackTipPlan( event: TipEvent, ctx: HandlerContext, @@ -2725,6 +2778,21 @@ async function postSlackPaymentConfirmation( confirmUrl: string, options: { label: string; message: string; threadTs?: string }, ) { + if (ctx.provider.type === 'telegram') { + await postPrivateReply(event, event.user, { + card: chat.Card({ + children: [ + chat.CardText(`${options.message}\nLink expires in 10 minutes.`), + chat.Actions([ + chat.LinkButton({ label: options.label, style: 'primary', url: confirmUrl }), + ]), + ], + }), + fallbackText: `${options.message}: ${confirmUrl}`, + }) + return + } + const confirmUrlLabel = confirmUrl.replace(/(\/confirm\/.{8}).+$/, '$1...') const body = new URLSearchParams() body.set('channel', event.channel.id.replace(/^slack:/, '')) @@ -3632,6 +3700,16 @@ async function postPrivateReply( options: { providerId?: string; threadTs?: string } = {}, ) { const threadTs = options.threadTs ?? event.threadTs + if (event.channel.id.startsWith('telegram:')) { + try { + await getChat() + .channel(await getTelegram().openDM(user.userId)) + .post(message) + } catch { + await event.channel.post(Telegram.openInstruction()) + } + return + } if (options.providerId && typeof message === 'string') { const body = new URLSearchParams() body.set('channel', Slack.getChannelId(event.channel.id)) @@ -3746,6 +3824,14 @@ async function postTipResult( return } if (result.code === 'insufficient_funds') { + if (ctx.provider.type === 'telegram') { + await postPrivateReply( + event, + event.user, + 'Payment not sent. Your wallet has insufficient funds.', + ) + return + } await postSlackInsufficientFunds(event, ctx, threadTs) return } @@ -3791,7 +3877,8 @@ async function postTipResult( : undefined, threadTs, ) - if (result.memo && threadTs) await postSlackMemoReply(event, ctx, result.memo, threadTs) + if (result.memo && threadTs && ctx.provider.type === 'slack') + await postSlackMemoReply(event, ctx, result.memo, threadTs) return } await postPrivateReply(event, event.user, 'Payment already sent.') @@ -3807,6 +3894,17 @@ async function postSlackQueuedTipMessage( threadTs?: string }, ) { + if (ctx.provider.type === 'telegram') { + const amount = result.isDefaultToken + ? formatCurrencyAmount(result.amount, result.tokenCurrency) + : formatTipAmount(result.amount, result.tokenCurrency, result.tokenSymbol) + const text = `${options.mentionUser(result.senderProviderUserId)} queued ${options.mentionUser(result.recipientProviderUserId)} ${amount}${result.memo ? ` for ${result.memo}` : ''}. ${options.mentionUser(result.recipientProviderUserId)} needs to start Tipbot to claim.` + const message = await getChat() + .channel(options.threadTs ? `${options.channelId}:${options.threadTs}` : options.channelId) + .post(text) + return message.id + } + const installation = await getSlack().getInstallation(ctx.channelProviderId ?? ctx.provider.id) if (!installation) throw new Error('Tibot app not installed for this workspace.') @@ -4279,6 +4377,14 @@ async function postSlackReceiptMessage( context?: string, threadTs?: string, ) { + if (ctx.provider.type === 'telegram') { + const receiptText = text.replace(/\.$/, '') + const receiptLink = Tempo.formatTxLink(chainId, transactionHash) + const channel = threadTs ? getChat().channel(`${event.channel.id}:${threadTs}`) : event.channel + await channel.post(`${receiptText} · Receipt: ${receiptLink}${context ? `\n${context}` : ''}`) + return + } + const installation = await getSlack().getInstallation(ctx.channelProviderId ?? ctx.provider.id) if (!installation) throw new Error('Tibot app not installed for this workspace.') @@ -4811,3 +4917,73 @@ function workspaceTokenOptions(chainId?: number) { if (chainId === undefined) return tokenOptions return tokenOptions.filter((option) => Tempo.isAllowedToken(chainId, option.address)) } + +export async function handleTelegramUpdate(update: unknown) { + const raw = Telegram.parseUpdate(update) + if (!raw.from) return + await handleTelegramMessageRaw( + getChat().channel(Telegram.channelId(raw)), + { + fullName: Telegram.userLabel(raw.from), + isBot: raw.from.is_bot, + isMe: false, + userId: String(raw.from.id), + userName: Telegram.userLabel(raw.from), + }, + raw, + ) +} + +async function handleTelegramMessageRaw( + channel: chat.Channel, + author: chat.Author, + raw: Telegram.Message, +) { + if (!raw.from || raw.from.is_bot) return + if (!Telegram.isGroupMessage(raw)) return + + const db = DB.create(env.DB) + const workspace = await Telegram.ensureWorkspace(db, raw) + await Telegram.ensureMessageMembers(db, workspace, raw) + const parsed = await Telegram.parseMention(db, workspace, raw, { + botUsername: env.TELEGRAM_BOT_USERNAME, + commandPattern, + }) + const threadTs = Telegram.threadTs(raw) + const event = { + channel, + threadTs, + user: author, + } satisfies TipEvent + const ctx = { + allowUninstalledWorkspaceCreate: true, + db, + provider: { id: String(raw.chat.id), type: 'telegram' }, + text: parsed.text, + threadTs, + } satisfies HandlerContext + + if (parsed.unresolvedMention) { + await channel.post( + `Payment not sent. I don’t know ${parsed.unresolvedMention} yet. Ask them to message this group or start Tipbot, then try again.`, + ) + return + } + if (parsed.command === 'config') { + await channel.post(Telegram.configText(workspace)) + return + } + if (parsed.command && parsed.command !== 'default') { + const command = z.parse(z.enum(commandNames), parsed.command) + await handlers[command](event, ctx) + return + } + await handlers.default(event, { + ...ctx, + defaultTip: { + idempotencyKey: `telegram:${raw.chat.id}:${raw.message_id}`, + mention: true, + threadTs, + }, + }) +} diff --git a/src/lib/confirmation.ts b/src/lib/confirmation.ts index 8510ee7..51fde92 100644 --- a/src/lib/confirmation.ts +++ b/src/lib/confirmation.ts @@ -13,7 +13,7 @@ export const payloadSchema = z.object({ kind: z.enum(['reusable_access_key', 'onetime_payment']), memo: z.string().nullable(), nonce: z.string().min(1), - provider: z.literal('slack'), + provider: z.enum(['slack', 'telegram']), providerChannelId: z.string().min(1), providerId: z.string().min(1), providerThreadId: z.string().min(1).optional(), diff --git a/src/lib/migrations.test.ts b/src/lib/migrations.test.ts index 46c7e39..44833ad 100644 --- a/src/lib/migrations.test.ts +++ b/src/lib/migrations.test.ts @@ -34,6 +34,65 @@ SELECT COUNT(*) AS "count" FROM "reaction_tip_thread" WHERE "message_ts" = '1000 expect(duplicateCount).toBe('1') }) +test('0020 allows Telegram provider rows while preserving Slack rows', () => { + const [ + slackWorkspaceCount, + workspaceSettings, + channelProviderId, + telegramWorkspaceCount, + telegramIdentityCount, + telegramBatchCount, + ] = runSqlite(` + ${migrationsSql({ before: '0020_telegram_provider.sql' })} + + INSERT INTO "account" ("id", "address", "created_at", "updated_at") + VALUES ('account', '0x0000000000000000000000000000000000000001', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + + INSERT INTO "workspace" ("id", "provider", "provider_id", "created_at", "updated_at", "default_token_address", "chain_id", "installed_at", "uninstalled_at") + VALUES ('slack-workspace', 'slack', 'TWORKSPACE', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', '0x0000000000000000000000000000000000000002', 42161, '2026-01-02T00:00:00.000Z', '2026-01-03T00:00:00.000Z'); + + INSERT INTO "provider_identity" ("id", "provider", "provider_workspace_id", "provider_user_id", "created_at", "updated_at") + VALUES ('slack-identity', 'slack', 'TWORKSPACE', 'U123', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + + INSERT INTO "member" ("id", "workspace_id", "provider_identity_id", "provider_user_id", "created_at", "updated_at") + VALUES ('slack-member', 'slack-workspace', 'slack-identity', 'U123', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + + INSERT INTO "account_link_token" ("id", "account_id", "member_id", "token_hash", "access_key_address", "access_key_public_key", "access_key_ciphertext", "access_key_expires_at", "expires_at", "created_at", "channel_provider_id") + VALUES ('token', 'account', 'slack-member', 'hash', '0x0000000000000000000000000000000000000003', 'public', 'ciphertext', '2026-01-04T00:00:00.000Z', '2026-01-04T00:00:00.000Z', '2026-01-01T00:00:00.000Z', 'slack'); + + ${migrationSql('0020_telegram_provider.sql')} + + INSERT INTO "workspace" ("id", "provider", "provider_id", "created_at", "updated_at") + VALUES ('telegram-workspace', 'telegram', '-100123', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + + INSERT INTO "provider_identity" ("id", "provider", "provider_workspace_id", "provider_user_id", "created_at", "updated_at") + VALUES ('telegram-identity', 'telegram', '-100123', '456', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + + INSERT INTO "member" ("id", "workspace_id", "provider_identity_id", "provider_user_id", "created_at", "updated_at") + VALUES ('telegram-member', 'telegram-workspace', 'telegram-identity', '456', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + + INSERT INTO "tip_batch" ("id", "workspace_id", "idempotency_key", "sender_member_id", "provider", "provider_id", "provider_channel_id", "amount_each", "total_amount", "recipient_count", "token_address", "status", "created_at", "updated_at") + VALUES ('telegram-batch', 'telegram-workspace', 'telegram-batch-key', 'telegram-member', 'telegram', '-100123', 'telegram:-100123', 1000, 1000, 1, '0x0000000000000000000000000000000000000001', 'pending', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + +.mode list +SELECT COUNT(*) FROM "workspace" WHERE "provider" = 'slack' AND "provider_id" = 'TWORKSPACE'; +SELECT "default_token_address" || '|' || "chain_id" || '|' || "installed_at" || '|' || "uninstalled_at" FROM "workspace" WHERE "id" = 'slack-workspace'; +SELECT "channel_provider_id" FROM "account_link_token" WHERE "id" = 'token'; +SELECT COUNT(*) FROM "workspace" WHERE "provider" = 'telegram' AND "provider_id" = '-100123'; +SELECT COUNT(*) FROM "provider_identity" WHERE "provider" = 'telegram' AND "provider_user_id" = '456'; +SELECT COUNT(*) FROM "tip_batch" WHERE "provider" = 'telegram' AND "id" = 'telegram-batch'; + `) + + expect(slackWorkspaceCount).toBe('1') + expect(workspaceSettings).toBe( + '0x0000000000000000000000000000000000000002|42161|2026-01-02T00:00:00.000Z|2026-01-03T00:00:00.000Z', + ) + expect(channelProviderId).toBe('slack') + expect(telegramWorkspaceCount).toBe('1') + expect(telegramIdentityCount).toBe('1') + expect(telegramBatchCount).toBe('1') +}) + function runSqlite(sql: string) { return execFileSync('sqlite3', [':memory:'], { encoding: 'utf8', input: sql }) .trim() diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts new file mode 100644 index 0000000..19672fe --- /dev/null +++ b/src/lib/telegram.ts @@ -0,0 +1,309 @@ +import * as DB from '#db/client.ts' +import type { DB as DB_gen } from '#db/types.gen.ts' +import { formatAmount } from '#/lib/format.ts' +import * as Nanoid from '#/lib/nanoid.ts' +import * as Tempo from '#/lib/tempo.ts' +import { createTelegramAdapter } from '@chat-adapter/telegram' +import { env } from 'cloudflare:workers' +import { z } from 'zod' + +let adapter: ReturnType | null = null +export function getAdapter() { + if (adapter) return adapter + adapter = createTelegramAdapter({ + apiUrl: env.TELEGRAM_API_URL, + botToken: env.TELEGRAM_BOT_TOKEN, + mode: 'webhook', + secretToken: env.TELEGRAM_WEBHOOK_SECRET_TOKEN, + userName: env.TELEGRAM_BOT_USERNAME, + }) + return adapter +} + +export const messageSchema = z.object({ + chat: z.object({ + id: z.number(), + title: z.string().optional(), + type: z.enum(['private', 'group', 'supergroup', 'channel']), + username: z.string().optional(), + }), + entities: z + .array( + z.object({ + length: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + type: z.string(), + user: z + .object({ + first_name: z.string(), + id: z.number(), + is_bot: z.boolean(), + last_name: z.string().optional(), + username: z.string().optional(), + }) + .optional(), + }), + ) + .optional(), + from: z + .object({ + first_name: z.string(), + id: z.number(), + is_bot: z.boolean(), + last_name: z.string().optional(), + username: z.string().optional(), + }) + .optional(), + message_id: z.number().int().positive(), + message_thread_id: z.number().int().positive().optional(), + reply_to_message: z + .object({ + from: z + .object({ + first_name: z.string(), + id: z.number(), + is_bot: z.boolean(), + last_name: z.string().optional(), + username: z.string().optional(), + }) + .optional(), + }) + .optional(), + text: z.string().default(''), +}) + +export type Message = z.infer + +export type ParsedMention = + | { + command: string + text: string + unresolvedMention?: undefined + } + | { command?: undefined; text: string; unresolvedMention: string } + +export function channelId(message: Message) { + return `telegram:${message.chat.id}` +} + +export function configText(workspace: DB_gen.Selectable.workspace) { + return [ + 'Workspace settings', + `Network ${Tempo.getChainName(workspace.chain_id)}`, + `Default amount ${formatAmount(workspace.default_amount)}`, + `Default token ${Tempo.getTokenMetadataFallback(workspace.default_token_address ?? Tempo.addressLookup.pathUsd).symbol}`, + 'Telegram settings editing coming soon.', + ].join('\n') +} + +export function isGroupMessage(message: Message) { + return ['group', 'supergroup'].includes(message.chat.type) +} + +export function openInstruction() { + return `Open @${env.TELEGRAM_BOT_USERNAME.replace(/^@+/, '')}, press Start, then try again.` +} + +export async function ensureMessageMembers( + db: DB.Type, + workspace: DB_gen.Selectable.workspace, + message: Message, +) { + if (message.from) await ensureMember(db, workspace, message.from) + if (message.reply_to_message?.from && !message.reply_to_message.from.is_bot) + await ensureMember(db, workspace, message.reply_to_message.from) + for (const entity of message.entities ?? []) + if (entity.type === 'text_mention' && entity.user && !entity.user.is_bot) + await ensureMember(db, workspace, entity.user) +} + +export async function ensureWorkspace(db: DB.Type, message: Message) { + const now = new Date().toISOString() + await db + .insertInto('workspace') + .values({ + created_at: now, + default_amount: 1000, + id: Nanoid.generate(), + installed_at: now, + name: message.chat.title ?? message.chat.username ?? String(message.chat.id), + provider: 'telegram', + provider_id: String(message.chat.id), + uninstalled_at: null, + updated_at: now, + }) + .onConflict((oc) => + oc.columns(['provider', 'provider_id']).doUpdateSet({ + name: message.chat.title ?? message.chat.username ?? String(message.chat.id), + updated_at: now, + }), + ) + .execute() + return await db + .selectFrom('workspace') + .selectAll() + .where('provider', '=', 'telegram') + .where('provider_id', '=', String(message.chat.id)) + .executeTakeFirstOrThrow() +} + +export async function parseMention( + db: DB.Type, + workspace: DB_gen.Selectable.workspace, + message: Message, + options: { botUsername: string; commandPattern: RegExp }, +): Promise { + const replacements: { end: number; start: number; value: string }[] = [] + const botName = options.botUsername.replace(/^@+/, '').toLowerCase() + for (const entity of message.entities ?? []) { + const value = message.text.slice(entity.offset, entity.offset + entity.length) + if (entity.type === 'bot_command') { + const command = value.replace(/^\//, '').split('@') + if (command[1] && command[1].toLowerCase() !== botName) continue + replacements.push({ + end: entity.offset + entity.length, + start: entity.offset, + value: command[0]!, + }) + continue + } + if (entity.type === 'text_mention' && entity.user && !entity.user.is_bot) { + replacements.push({ + end: entity.offset + entity.length, + start: entity.offset, + value: `<@${entity.user.id}|${userLabel(entity.user)}>`, + }) + continue + } + if (entity.type !== 'mention') continue + if (value.replace(/^@+/, '').toLowerCase() === botName) { + replacements.push({ end: entity.offset + entity.length, start: entity.offset, value: ' ' }) + continue + } + const resolved = await resolveUsername(db, workspace.id, value) + if (!resolved) return { text: '', unresolvedMention: value } + replacements.push({ + end: entity.offset + entity.length, + start: entity.offset, + value: `<@${resolved.provider_user_id}|${resolved.display_name ?? value}>`, + }) + } + let text = message.text + for (const replacement of replacements.sort((a, b) => b.start - a.start)) + text = `${text.slice(0, replacement.start)}${replacement.value}${text.slice(replacement.end)}` + text = text.replace(new RegExp(`(^|\\s)@${escapeRegex(botName)}\\b`, 'i'), ' ').trim() + const match = text.match(options.commandPattern) + if (match) return { command: match[1]!, text: match[2]?.trim() ?? '' } + if ( + !/^<@\d+(?:\|[^>]+)?>/.test(text) && + message.reply_to_message?.from && + !message.reply_to_message.from.is_bot + ) + text = + `<@${message.reply_to_message.from.id}|${userLabel(message.reply_to_message.from)}> ${text}`.trim() + return { command: 'default', text } +} + +export function parseUpdate(update: unknown) { + return z.parse(z.object({ message: messageSchema }).passthrough(), update).message +} + +export function threadTs(message: Message) { + return message.message_thread_id ? String(message.message_thread_id) : undefined +} + +export function userLabel(user: { first_name: string; last_name?: string; username?: string }) { + return user.username + ? `@${user.username}` + : [user.first_name, user.last_name].filter(Boolean).join(' ') +} + +async function ensureMember( + db: DB.Type, + workspace: DB_gen.Selectable.workspace, + user: NonNullable, +) { + const now = new Date().toISOString() + const label = userLabel(user) + await db + .insertInto('provider_identity') + .values({ + account_id: null, + created_at: now, + display_name: label, + id: Nanoid.generate(), + metadata: JSON.stringify({ username: user.username ?? null }), + provider: 'telegram', + provider_global_user_id: String(user.id), + provider_user_id: String(user.id), + provider_workspace_id: workspace.provider_id, + real_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null, + updated_at: now, + }) + .execute() + .catch((error) => { + if (!isUniqueConstraintError(error)) throw error + }) + await db + .updateTable('provider_identity') + .set({ + display_name: label, + metadata: JSON.stringify({ username: user.username ?? null }), + real_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null, + updated_at: now, + }) + .where('provider', '=', 'telegram') + .where('provider_workspace_id', '=', workspace.provider_id) + .where('provider_user_id', '=', String(user.id)) + .execute() + const identity = await db + .selectFrom('provider_identity') + .select('id') + .where('provider', '=', 'telegram') + .where('provider_workspace_id', '=', workspace.provider_id) + .where('provider_user_id', '=', String(user.id)) + .executeTakeFirstOrThrow() + await db + .insertInto('member') + .values({ + created_at: now, + id: Nanoid.generate(), + login: user.username ? `@${user.username}` : null, + name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null, + provider_identity_id: identity.id, + provider_user_id: String(user.id), + updated_at: now, + workspace_id: workspace.id, + }) + .onConflict((oc) => + oc.columns(['workspace_id', 'provider_user_id']).doUpdateSet({ + login: user.username ? `@${user.username}` : null, + name: [user.first_name, user.last_name].filter(Boolean).join(' ') || null, + provider_identity_id: identity.id, + updated_at: now, + }), + ) + .execute() +} + +async function resolveUsername(db: DB.Type, workspaceId: string, username: string) { + const rows = await db + .selectFrom('member') + .innerJoin('provider_identity', 'provider_identity.id', 'member.provider_identity_id') + .select(['provider_identity.display_name', 'member.provider_user_id']) + .where('member.workspace_id', '=', workspaceId) + .where((eb) => eb.fn('lower', ['provider_identity.display_name']), '=', username.toLowerCase()) + .execute() + return rows.length === 1 ? rows[0] : null +} + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function isUniqueConstraintError(error: unknown) { + return ( + error instanceof Error && + /UNIQUE constraint failed|SQLITE_CONSTRAINT_UNIQUE/.test(error.message) + ) +} diff --git a/src/lib/tip.ts b/src/lib/tip.ts index 6c637a7..6ab7f59 100644 --- a/src/lib/tip.ts +++ b/src/lib/tip.ts @@ -1538,7 +1538,7 @@ async function createConfirmationRequired( amount?: number idempotencyKey: string memo: string | null - provider: 'slack' + provider: Database.Selectable.workspace['provider'] providerChannelId: string providerId: string providerThreadId?: string @@ -1628,7 +1628,7 @@ async function submitTipBatch( idempotencyKey: string keyAuthorization: KeyAuthorization.Signed memo: string | null - provider: 'slack' + provider: Database.Selectable.workspace['provider'] providerChannelId: string providerId: string providerThreadId?: string diff --git a/src/worker-configuration.d.ts b/src/worker-configuration.d.ts index 41631a8..e9b9704 100644 --- a/src/worker-configuration.d.ts +++ b/src/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types src/worker-configuration.d.ts` (hash: a4baac7bd74e1d1eaa3d3b55759e6b4c) +// Generated by Wrangler by running `wrangler types src/worker-configuration.d.ts` (hash: 605aa61b3f8d7ac2455dfb3cc35a782d) // Runtime types generated with workerd@1.20260511.1 2026-05-07 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -13,6 +13,8 @@ declare namespace Cloudflare { CF_VERSION_METADATA: WorkerVersionMetadata; HOST: "__PREVIEW_APEX__.tip.bot"; SLACK_API_URL: "https://slack.com/api"; + TELEGRAM_API_URL: "https://api.telegram.org"; + TELEGRAM_BOT_USERNAME: "__PREVIEW_TELEGRAM_BOT_USERNAME__"; TWITTER_API_URL: "https://api.twitter.com"; TWITTER_BOT_HANDLE: "__PREVIEW_TWITTER_BOT_HANDLE__"; FEE_PAYER_PRIVATE_KEY_MAINNET: string; @@ -21,6 +23,8 @@ declare namespace Cloudflare { SLACK_CLIENT_ID: string; SLACK_CLIENT_SECRET: string; SLACK_SIGNING_SECRET: string; + TELEGRAM_BOT_TOKEN: string; + TELEGRAM_WEBHOOK_SECRET_TOKEN: string; TWITTER_OAUTH_CLIENT_ID: string; TWITTER_OAUTH_CLIENT_SECRET: string; CHAT_STATE: DurableObjectNamespace; @@ -33,6 +37,8 @@ declare namespace Cloudflare { CF_VERSION_METADATA: WorkerVersionMetadata; HOST: "tip.bot"; SLACK_API_URL: "https://slack.com/api"; + TELEGRAM_API_URL: "https://api.telegram.org"; + TELEGRAM_BOT_USERNAME: "ggtipbot"; TWITTER_API_URL: "https://api.twitter.com"; TWITTER_BOT_HANDLE: "tipbotgg"; FEE_PAYER_PRIVATE_KEY_MAINNET: string; @@ -41,6 +47,8 @@ declare namespace Cloudflare { SLACK_CLIENT_ID: string; SLACK_CLIENT_SECRET: string; SLACK_SIGNING_SECRET: string; + TELEGRAM_BOT_TOKEN: string; + TELEGRAM_WEBHOOK_SECRET_TOKEN: string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_BEARER_TOKEN: string; @@ -58,6 +66,8 @@ declare namespace Cloudflare { CF_VERSION_METADATA: WorkerVersionMetadata; HOST: "__PREVIEW_APEX__.tip.bot" | "tip.bot" | "tipbot.localhost"; SLACK_API_URL: "https://slack.com/api"; + TELEGRAM_API_URL: "https://api.telegram.org"; + TELEGRAM_BOT_USERNAME: "__PREVIEW_TELEGRAM_BOT_USERNAME__" | "ggtipbot"; TWITTER_API_URL: "https://api.twitter.com"; TWITTER_BOT_HANDLE: "__PREVIEW_TWITTER_BOT_HANDLE__" | "tipbotgg"; FEE_PAYER_PRIVATE_KEY_MAINNET: string; @@ -66,6 +76,8 @@ declare namespace Cloudflare { SLACK_CLIENT_ID: string; SLACK_CLIENT_SECRET: string; SLACK_SIGNING_SECRET: string; + TELEGRAM_BOT_TOKEN: string | "000000:test"; + TELEGRAM_WEBHOOK_SECRET_TOKEN: string | "telegram-webhook-secret"; TWITTER_OAUTH_CLIENT_ID: string; TWITTER_OAUTH_CLIENT_SECRET: string; CHAT_STATE: DurableObjectNamespace; @@ -82,7 +94,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index a932b36..55fddf0 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -78,6 +78,10 @@ "vars": { "HOST": "tipbot.localhost", "SLACK_API_URL": "https://slack.com/api", + "TELEGRAM_API_URL": "https://api.telegram.org", + "TELEGRAM_BOT_TOKEN": "000000:test", + "TELEGRAM_BOT_USERNAME": "ggtipbot", + "TELEGRAM_WEBHOOK_SECRET_TOKEN": "telegram-webhook-secret", "TWITTER_API_URL": "https://api.twitter.com", "TWITTER_BOT_HANDLE": "tipbotgg", }, @@ -145,6 +149,8 @@ "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_WEBHOOK_SECRET_TOKEN", "TWITTER_OAUTH_CLIENT_ID", "TWITTER_OAUTH_CLIENT_SECRET", ], @@ -152,6 +158,8 @@ "vars": { "HOST": "__PREVIEW_APEX__.tip.bot", "SLACK_API_URL": "https://slack.com/api", + "TELEGRAM_API_URL": "https://api.telegram.org", + "TELEGRAM_BOT_USERNAME": "__PREVIEW_TELEGRAM_BOT_USERNAME__", "TWITTER_API_URL": "https://api.twitter.com", "TWITTER_BOT_HANDLE": "__PREVIEW_TWITTER_BOT_HANDLE__", }, @@ -221,6 +229,8 @@ "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SLACK_SIGNING_SECRET", + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_WEBHOOK_SECRET_TOKEN", "TWITTER_ACCESS_TOKEN", "TWITTER_ACCESS_TOKEN_SECRET", "TWITTER_BEARER_TOKEN", @@ -233,6 +243,8 @@ "vars": { "HOST": "tip.bot", "SLACK_API_URL": "https://slack.com/api", + "TELEGRAM_API_URL": "https://api.telegram.org", + "TELEGRAM_BOT_USERNAME": "ggtipbot", "TWITTER_API_URL": "https://api.twitter.com", "TWITTER_BOT_HANDLE": "tipbotgg", },