├── .formatter.exs
├── .gitignore
├── LICENSE
├── README.asc
├── assets
├── brunch-config.js
├── css
│ ├── app.css
│ └── phoenix.css
├── js
│ ├── app.js
│ └── socket.js
├── package-lock.json
├── package.json
└── static
│ ├── favicon.ico
│ ├── images
│ └── phoenix.png
│ └── robots.txt
├── bin
└── build.sh
├── config
├── config.exs
├── dev.exs
├── prod.exs
├── releases.exs
└── test.exs
├── lib
├── release_tasks.ex
├── tweet_bot.ex
├── tweet_bot
│ ├── accounts
│ │ ├── accounts.ex
│ │ └── user.ex
│ ├── application.ex
│ └── repo.ex
├── tweet_bot_web.ex
└── tweet_bot_web
│ ├── channels
│ └── user_socket.ex
│ ├── controllers
│ ├── auth_controller.ex
│ ├── fallback_controller.ex
│ ├── page_controller.ex
│ ├── twitter.ex
│ ├── twitter_api.ex
│ └── twitter_controller.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── router.ex
│ ├── templates
│ ├── layout
│ │ └── app.html.eex
│ └── page
│ │ └── index.html.eex
│ └── views
│ ├── error_helpers.ex
│ ├── error_view.ex
│ ├── layout_view.ex
│ └── page_view.ex
├── mix.exs
├── mix.lock
├── notes
├── add-routes.asc
├── delete-tweet.asc
├── demon-in-details.asc
├── deploy.asc
├── high-availability.asc
├── migrate-distillery-to-mix-release.asc
├── more-tests.asc
├── ngrok-web-interface.jpeg
├── optimize-and-fix.asc
├── plan.asc
├── ready-go.asc
├── reply.asc
├── save-user.asc
├── security.asc
├── send-tweet.asc
├── tweet-photo.asc
├── twitter-oauth.asc
├── user-test.asc
└── who-is-that.asc
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
└── repo
│ ├── migrations
│ ├── 20180921014945_create_users.exs
│ ├── 20180921113934_add_access_token_secret_to_users.exs
│ └── 20180921133030_alter_users.exs
│ └── seeds.exs
├── rel
├── env.bat.eex
├── env.sh.eex
└── vm.args.eex
└── test
├── support
├── channel_case.ex
├── conn_case.ex
├── data_case.ex
└── mocks.ex
├── test_helper.exs
├── tweet_bot
└── accounts
│ └── accounts_test.exs
└── tweet_bot_web
├── controllers
├── page_controller_test.exs
└── twitter_controller_test.exs
└── views
├── error_view_test.exs
├── layout_view_test.exs
└── page_view_test.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | inputs: ["*.{ex,exs}", "{config,lib,priv,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # App artifacts
2 | /_build
3 | /db
4 | /deps
5 | /*.ez
6 |
7 | # Generated on crash by the VM
8 | erl_crash.dump
9 |
10 | # Generated on crash by NPM
11 | npm-debug.log
12 |
13 | # Static artifacts
14 | /assets/node_modules
15 |
16 | # Since we are building assets from assets/,
17 | # we ignore priv/static. You may want to comment
18 | # this depending on your deployment strategy.
19 | /priv/static/
20 |
21 | # Files matching config/*.secret.exs pattern contain sensitive
22 | # data and you should not commit them into version control.
23 | #
24 | # Alternatively, you may comment the line below and commit the
25 | # secrets files as long as you replace their contents by environment
26 | # variables.
27 | /config/*.secret.exs
28 |
29 | .elixir_ls
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) [2018] [Sam Chen]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.asc:
--------------------------------------------------------------------------------
1 | = Phoenix Framework 开发笔记
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 |
6 | 我曾用 Node.js 写过一个简单的 telegram 发推机器人,但后面整理硬盘时,觉得用不上,就删掉了代码 - 包括 Github 仓库上的一份。
7 |
8 | 最近发现又有这个需求,就决定用 https://github.com/phoenixframework/phoenix[Phoenix Framework] 实现一遍。这个仓库保存的正是发推机器人的源代码及开发笔记。
9 |
10 | WARNING: 因为是笔记,所以这里不会解释基础的 Elixir 或 Phoenix 知识。
11 |
12 | . Elixir 1.7.3
13 | . OTP 21.0.9
14 | . Phoenix Framework 1.4.0
15 | . PostgreSQL
16 |
17 | == 目录
18 |
19 | . link:notes/ready-go.asc[准备工作]
20 | . link:notes/plan.asc[规划]
21 | . link:notes/add-routes.asc[添加路由]
22 | . link:notes/user-test.asc[测试 `User`]
23 | . link:notes/reply.asc[回复你好]
24 | . link:notes/twitter-oauth.asc[OAuth 认证]
25 | . link:notes/save-user.asc[保存用户]
26 | . link:notes/who-is-that.asc[用户授权了吗]
27 | . link:notes/send-tweet.asc[发推]
28 | . link:notes/delete-tweet.asc[删推]
29 | . link:notes/demon-in-details.asc[细节中藏着魔鬼]
30 | . link:notes/tweet-photo.asc[发送图片]
31 | . link:notes/security.asc[关于安全]
32 | . link:notes/optimize-and-fix.asc[优化与修补]
33 | . link:notes/more-tests.asc[测试]
34 | . link:notes/deploy.asc[部署 Phoenix Framework]
35 | . link:notes/high-availability.asc[高可用]
36 | . link:notes/migrate-distillery-to-mix-release.asc[从 distillery 迁移至 mix release]
37 |
38 | == License
39 |
40 | MIT License
41 |
42 | © 陈三,2018 - 2019
43 |
--------------------------------------------------------------------------------
/assets/brunch-config.js:
--------------------------------------------------------------------------------
1 | exports.config = {
2 | // See http://brunch.io/#documentation for docs.
3 | files: {
4 | javascripts: {
5 | joinTo: "js/app.js"
6 |
7 | // To use a separate vendor.js bundle, specify two files path
8 | // http://brunch.io/docs/config#-files-
9 | // joinTo: {
10 | // "js/app.js": /^js/,
11 | // "js/vendor.js": /^(?!js)/
12 | // }
13 | //
14 | // To change the order of concatenation of files, explicitly mention here
15 | // order: {
16 | // before: [
17 | // "vendor/js/jquery-2.1.1.js",
18 | // "vendor/js/bootstrap.min.js"
19 | // ]
20 | // }
21 | },
22 | stylesheets: {
23 | joinTo: "css/app.css"
24 | },
25 | templates: {
26 | joinTo: "js/app.js"
27 | }
28 | },
29 |
30 | conventions: {
31 | // This option sets where we should place non-css and non-js assets in.
32 | // By default, we set this to "/assets/static". Files in this directory
33 | // will be copied to `paths.public`, which is "priv/static" by default.
34 | assets: /^(static)/
35 | },
36 |
37 | // Phoenix paths configuration
38 | paths: {
39 | // Dependencies and current project directories to watch
40 | watched: ["static", "css", "js", "vendor"],
41 | // Where to compile files to
42 | public: "../priv/static"
43 | },
44 |
45 | // Configure your plugins
46 | plugins: {
47 | babel: {
48 | // Do not use ES6 compiler in vendor code
49 | ignore: [/vendor/]
50 | }
51 | },
52 |
53 | modules: {
54 | autoRequire: {
55 | "js/app.js": ["js/app"]
56 | }
57 | },
58 |
59 | npm: {
60 | enabled: true
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application css. */
2 | .leading {
3 | padding: 40px 15px;
4 | text-align: center;
5 | }
6 | .main {
7 | margin-top: 100px;
8 | }
9 | @media (min-width: 768px) {
10 | .main {
11 | margin-top: 0;
12 | }
13 | }
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // Brunch automatically concatenates all files in your
2 | // watched paths. Those paths can be configured at
3 | // config.paths.watched in "brunch-config.js".
4 | //
5 | // However, those files will only be executed if
6 | // explicitly imported. The only exception are files
7 | // in vendor, which are never wrapped in imports and
8 | // therefore are always executed.
9 |
10 | // Import dependencies
11 | //
12 | // If you no longer want to use a dependency, remember
13 | // to also remove its path from "config.paths.watched".
14 | import "phoenix_html"
15 |
16 | // Import local files
17 | //
18 | // Local files can be imported directly using relative
19 | // paths "./socket" or full ones "web/static/js/socket".
20 |
21 | // import socket from "./socket"
22 |
--------------------------------------------------------------------------------
/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "assets/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket
5 | // and connect at the socket path in "lib/web/endpoint.ex":
6 | import {Socket} from "phoenix"
7 |
8 | let socket = new Socket("/socket", {params: {token: window.userToken}})
9 |
10 | // When you connect, you'll often need to authenticate the client.
11 | // For example, imagine you have an authentication plug, `MyAuth`,
12 | // which authenticates the session and assigns a `:current_user`.
13 | // If the current user exists you can assign the user's token in
14 | // the connection for use in the layout.
15 | //
16 | // In your "lib/web/router.ex":
17 | //
18 | // pipeline :browser do
19 | // ...
20 | // plug MyAuth
21 | // plug :put_user_token
22 | // end
23 | //
24 | // defp put_user_token(conn, _) do
25 | // if current_user = conn.assigns[:current_user] do
26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
27 | // assign(conn, :user_token, token)
28 | // else
29 | // conn
30 | // end
31 | // end
32 | //
33 | // Now you need to pass this token to JavaScript. You can do so
34 | // inside a script tag in "lib/web/templates/layout/app.html.eex":
35 | //
36 | //
37 | //
38 | // You will need to verify the user token in the "connect/2" function
39 | // in "lib/web/channels/user_socket.ex":
40 | //
41 | // def connect(%{"token" => token}, socket) do
42 | // # max_age: 1209600 is equivalent to two weeks in seconds
43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
44 | // {:ok, user_id} ->
45 | // {:ok, assign(socket, :user, user_id)}
46 | // {:error, reason} ->
47 | // :error
48 | // end
49 | // end
50 | //
51 | // Finally, pass the token on connect as below. Or remove it
52 | // from connect if you don't care about authentication.
53 |
54 | socket.connect()
55 |
56 | // Now that you are connected, you can join channels with a topic:
57 | let channel = socket.channel("topic:subtopic", {})
58 | channel.join()
59 | .receive("ok", resp => { console.log("Joined successfully", resp) })
60 | .receive("error", resp => { console.log("Unable to join", resp) })
61 |
62 | export default socket
63 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "license": "MIT",
4 | "scripts": {
5 | "deploy": "brunch build --production",
6 | "watch": "brunch watch --stdin"
7 | },
8 | "dependencies": {
9 | "phoenix": "file:../deps/phoenix",
10 | "phoenix_html": "file:../deps/phoenix_html"
11 | },
12 | "devDependencies": {
13 | "babel-brunch": "^7.0.1",
14 | "brunch": "^2.10.17",
15 | "clean-css-brunch": "2.10.0",
16 | "uglify-js-brunch": "2.10.0"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/telegram-bot-for-twitter/892107c7609123028ac2375342cd7b2329931635/assets/static/favicon.ico
--------------------------------------------------------------------------------
/assets/static/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/telegram-bot-for-twitter/892107c7609123028ac2375342cd7b2329931635/assets/static/images/phoenix.png
--------------------------------------------------------------------------------
/assets/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/bin/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -e
4 |
5 | cd /opt/build/app
6 |
7 | APP_NAME="$(grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g')"
8 | APP_VSN="$(grep 'version:' mix.exs | cut -d '"' -f2)"
9 |
10 | export MIX_ENV=prod
11 |
12 | # Fetch deps and compile
13 | mix deps.get --only prod
14 | # Run an explicit clean to remove any build artifacts from the host
15 | mix do clean, compile --force
16 | cd ./assets
17 | npm install
18 | npm run deploy
19 | cd ..
20 | mix phx.digest
21 | # Build the release
22 | mix release
23 |
24 | # Copy tarball to output
25 | # cp "_build/prod/rel/$APP_NAME/releases/$APP_VSN/$APP_NAME.tar.gz" rel/artifacts/"$APP_NAME-$APP_VSN.tar.gz"
26 |
27 | exit 0
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 | use Mix.Config
7 |
8 | # General application configuration
9 | config :tweet_bot,
10 | ecto_repos: [TweetBot.Repo]
11 |
12 | # Configures the endpoint
13 | config :tweet_bot, TweetBotWeb.Endpoint,
14 | url: [host: "localhost"],
15 | secret_key_base: "hLANbXMahFIMbsvWL363rXGHPhxMz0gy01IN5pmhEhirz7CMQySyKgM02o9ek9oS",
16 | render_errors: [view: TweetBotWeb.ErrorView, accepts: ~w(html json)],
17 | pubsub: [name: TweetBot.PubSub, adapter: Phoenix.PubSub.PG2]
18 |
19 | # Configures Elixir's Logger
20 | config :logger, :console,
21 | format: "$time $metadata[$level] $message\n",
22 | metadata: [:user_id]
23 |
24 | config :tweet_bot,
25 | twitter_api: TwitterAPI
26 |
27 | config :phoenix, :json_library, Jason
28 |
29 | # Import environment specific config. This must remain at the bottom
30 | # of this file so it overrides the configuration defined above.
31 | import_config "#{Mix.env()}.exs"
32 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with brunch.io to recompile .js and .css sources.
9 | config :tweet_bot, TweetBotWeb.Endpoint,
10 | http: [port: 4000],
11 | debug_errors: true,
12 | code_reloader: true,
13 | check_origin: false,
14 | watchers: [
15 | node: [
16 | "node_modules/brunch/bin/brunch",
17 | "watch",
18 | "--stdin",
19 | cd: Path.expand("../assets", __DIR__)
20 | ]
21 | ]
22 |
23 | # ## SSL Support
24 | #
25 | # In order to use HTTPS in development, a self-signed
26 | # certificate can be generated by running the following
27 | # command from your terminal:
28 | #
29 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem
30 | #
31 | # The `http:` config above can be replaced with:
32 | #
33 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"],
34 | #
35 | # If desired, both `http:` and `https:` keys can be
36 | # configured to run both http and https servers on
37 | # different ports.
38 |
39 | # Watch static and templates for browser reloading.
40 | config :tweet_bot, TweetBotWeb.Endpoint,
41 | live_reload: [
42 | patterns: [
43 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
44 | ~r{priv/gettext/.*(po)$},
45 | ~r{lib/tweet_bot_web/views/.*(ex)$},
46 | ~r{lib/tweet_bot_web/templates/.*(eex)$}
47 | ]
48 | ]
49 |
50 | # Do not include metadata nor timestamps in development logs
51 | config :logger, :console, format: "[$level] $message\n"
52 |
53 | # Set a higher stacktrace during development. Avoid configuring such
54 | # in production as building large stacktraces may be expensive.
55 | config :phoenix, :stacktrace_depth, 20
56 |
57 | # Configure your database
58 | config :tweet_bot, TweetBot.Repo,
59 | adapter: Ecto.Adapters.Postgres,
60 | username: "postgres",
61 | password: "postgres",
62 | database: "tweet_bot_dev",
63 | hostname: "localhost",
64 | pool_size: 10
65 |
66 | # Configures token for telegram bot
67 | config :telegram_bot,
68 | token: System.get_env("TELEGRAM_TOKEN")
69 |
70 | # Configures extwitter oauth
71 | config :extwitter, :oauth,
72 | consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
73 | consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
74 |
75 | # Configures extwitter proxy
76 | config :extwitter, :proxy,
77 | server: "127.0.0.1",
78 | port: 1087
79 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For production, we often load configuration from external
4 | # sources, such as your system environment. For this reason,
5 | # you won't find the :http configuration below, but set inside
6 | # TweetBotWeb.Endpoint.init/2 when load_from_system_env is
7 | # true. Any dynamic configuration should be done there.
8 | #
9 | # Don't forget to configure the url host to something meaningful,
10 | # Phoenix uses this information when generating URLs.
11 | #
12 | # Finally, we also include the path to a cache manifest
13 | # containing the digested version of static files. This
14 | # manifest is generated by the mix phx.digest task
15 | # which you typically run after static files are built.
16 | config :tweet_bot, TweetBotWeb.Endpoint,
17 | http: [port: {:system, "PORT"}],
18 | url: [scheme: "https", host: "tweetbot.zfanw.com", port: 443],
19 | cache_static_manifest: "priv/static/cache_manifest.json",
20 | server: true,
21 | root: ".",
22 | version: Application.spec(:tweet_bot, :vsn)
23 |
24 | # Do not print debug messages in production
25 | config :logger, level: :info
26 |
27 | # Configure your database
28 | config :tweet_bot, TweetBot.Repo,
29 | pool_size: 3,
30 | show_sensitive_data_on_connection_error: true
31 |
32 | # ## SSL Support
33 | #
34 | # To get SSL working, you will need to add the `https` key
35 | # to the previous section and set your `:url` port to 443:
36 | #
37 | # config :tweet_bot, TweetBotWeb.Endpoint,
38 | # ...
39 | # url: [host: "example.com", port: 443],
40 | # https: [:inet6,
41 | # port: 443,
42 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
43 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")]
44 | #
45 | # Where those two env variables return an absolute path to
46 | # the key and cert in disk or a relative path inside priv,
47 | # for example "priv/ssl/server.key".
48 | #
49 | # We also recommend setting `force_ssl`, ensuring no data is
50 | # ever sent via http, always redirecting to https:
51 | #
52 | # config :tweet_bot, TweetBotWeb.Endpoint,
53 | # force_ssl: [hsts: true]
54 | #
55 | # Check `Plug.SSL` for all available options in `force_ssl`.
56 |
57 | # ## Using releases
58 | #
59 | # If you are doing OTP releases, you need to instruct Phoenix
60 | # to start the server for all endpoints:
61 | #
62 | # config :phoenix, :serve_endpoints, true
63 | #
64 | # Alternatively, you can configure exactly which server to
65 | # start per endpoint:
66 | #
67 | # config :tweet_bot, TweetBotWeb.Endpoint, server: true
68 | #
69 |
--------------------------------------------------------------------------------
/config/releases.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configures token for telegram bot
4 | config :telegram_bot,
5 | token: System.fetch_env!("TELEGRAM_TOKEN")
6 |
7 | # Configures extwitter oauth
8 | config :extwitter, :oauth,
9 | consumer_key: System.fetch_env!("TWITTER_CONSUMER_KEY"),
10 | consumer_secret: System.fetch_env!("TWITTER_CONSUMER_SECRET")
11 |
12 | config :tweet_bot, TweetBotWeb.Endpoint, secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
13 |
14 | # Configure your database
15 | config :tweet_bot, TweetBot.Repo,
16 | username: System.fetch_env!("DATABASE_USER"),
17 | password: System.fetch_env!("DATABASE_PASS"),
18 | database: System.fetch_env!("DATABASE_NAME"),
19 | hostname: System.fetch_env!("DATABASE_HOST")
20 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :tweet_bot, TweetBotWeb.Endpoint,
6 | http: [port: 4001],
7 | server: false
8 |
9 | # Print only warnings and errors during test
10 | config :logger, level: :warn
11 |
12 | # Configure your database
13 | config :tweet_bot, TweetBot.Repo,
14 | adapter: Ecto.Adapters.Postgres,
15 | username: "postgres",
16 | password: "postgres",
17 | database: "tweet_bot_test",
18 | hostname: "localhost",
19 | pool: Ecto.Adapters.SQL.Sandbox
20 |
21 | config :tweet_bot,
22 | twitter_api: TwitterMock
23 |
--------------------------------------------------------------------------------
/lib/release_tasks.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Release do
2 | @app :tweet_bot
3 |
4 | def migrate do
5 | load_app()
6 |
7 | for repo <- repos() do
8 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
9 | end
10 | end
11 |
12 | def rollback(repo, version) do
13 | load_app()
14 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
15 | end
16 |
17 | defp repos do
18 | Application.fetch_env!(@app, :ecto_repos)
19 | end
20 |
21 | defp load_app do
22 | Application.load(@app)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/tweet_bot.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot do
2 | @moduledoc """
3 | TweetBot keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tweet_bot/accounts/accounts.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Accounts do
2 | @moduledoc """
3 | The Accounts context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias TweetBot.Repo
8 |
9 | alias TweetBot.Accounts.User
10 |
11 | @doc """
12 | Returns the list of users.
13 |
14 | ## Examples
15 |
16 | iex> list_users()
17 | [%User{}, ...]
18 |
19 | """
20 | def list_users do
21 | Repo.all(User)
22 | end
23 |
24 | @doc """
25 | Gets a single user.
26 |
27 | Raises `Ecto.NoResultsError` if the User does not exist.
28 |
29 | ## Examples
30 |
31 | iex> get_user!(123)
32 | %User{}
33 |
34 | iex> get_user!(456)
35 | ** (Ecto.NoResultsError)
36 |
37 | """
38 | def get_user!(id), do: Repo.get!(User, id)
39 |
40 | def get_user_by_from_id(from_id) do
41 | Repo.get_by(User, from_id: from_id)
42 | end
43 |
44 | def get_user_by_from_id!(from_id) do
45 | Repo.get_by!(User, from_id: from_id)
46 | end
47 |
48 | @doc """
49 | Creates a user.
50 |
51 | ## Examples
52 |
53 | iex> create_user(%{field: value})
54 | {:ok, %User{}}
55 |
56 | iex> create_user(%{field: bad_value})
57 | {:error, %Ecto.Changeset{}}
58 |
59 | """
60 | def create_user(attrs \\ %{}) do
61 | %User{}
62 | |> User.changeset(attrs)
63 | |> Repo.insert()
64 | end
65 |
66 | @doc """
67 | Updates a user.
68 |
69 | ## Examples
70 |
71 | iex> update_user(user, %{field: new_value})
72 | {:ok, %User{}}
73 |
74 | iex> update_user(user, %{field: bad_value})
75 | {:error, %Ecto.Changeset{}}
76 |
77 | """
78 | def update_user(%User{} = user, attrs) do
79 | user
80 | |> User.changeset(attrs)
81 | |> Repo.update()
82 | end
83 |
84 | @doc """
85 | Deletes a User.
86 |
87 | ## Examples
88 |
89 | iex> delete_user(user)
90 | {:ok, %User{}}
91 |
92 | iex> delete_user(user)
93 | {:error, %Ecto.Changeset{}}
94 |
95 | """
96 | def delete_user(%User{} = user) do
97 | Repo.delete(user)
98 | end
99 |
100 | @doc """
101 | Returns an `%Ecto.Changeset{}` for tracking user changes.
102 |
103 | ## Examples
104 |
105 | iex> change_user(user)
106 | %Ecto.Changeset{source: %User{}}
107 |
108 | """
109 | def change_user(%User{} = user) do
110 | User.changeset(user, %{})
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/lib/tweet_bot/accounts/user.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Accounts.User do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "users" do
6 | field(:access_token, :string)
7 | field(:access_token_secret, :string)
8 | field(:from_id, :integer)
9 |
10 | timestamps()
11 | end
12 |
13 | @doc false
14 | def changeset(user, attrs) do
15 | user
16 | |> cast(attrs, [:from_id, :access_token, :access_token_secret])
17 | |> validate_required([:from_id, :access_token])
18 | |> unique_constraint(:from_id)
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/tweet_bot/application.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Application do
2 | use Application
3 |
4 | # See https://hexdocs.pm/elixir/Application.html
5 | # for more information on OTP Applications
6 | def start(_type, _args) do
7 | import Supervisor.Spec
8 |
9 | # Define workers and child supervisors to be supervised
10 | children = [
11 | # Start the Ecto repository
12 | supervisor(TweetBot.Repo, []),
13 | # Start the endpoint when the application starts
14 | supervisor(TweetBotWeb.Endpoint, []),
15 | # Start your own worker by calling: TweetBot.Worker.start_link(arg1, arg2, arg3)
16 | # worker(TweetBot.Worker, [arg1, arg2, arg3]),
17 | ]
18 |
19 | # See https://hexdocs.pm/elixir/Supervisor.html
20 | # for other strategies and supported options
21 | opts = [strategy: :one_for_one, name: TweetBot.Supervisor]
22 | Supervisor.start_link(children, opts)
23 | end
24 |
25 | # Tell Phoenix to update the endpoint configuration
26 | # whenever the application is updated.
27 | def config_change(changed, _new, removed) do
28 | TweetBotWeb.Endpoint.config_change(changed, removed)
29 | :ok
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/tweet_bot/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Repo do
2 | use Ecto.Repo, otp_app: :tweet_bot, adapter: Ecto.Adapters.Postgres
3 |
4 | @doc """
5 | Dynamically loads the repository url from the
6 | DATABASE_URL environment variable.
7 | """
8 | def init(_, opts) do
9 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use TweetBotWeb, :controller
9 | use TweetBotWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: TweetBotWeb
23 | import Plug.Conn
24 | import TweetBotWeb.Router.Helpers
25 | import TweetBotWeb.Gettext
26 | alias TweetBotWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View, root: "lib/tweet_bot_web/templates",
33 | namespace: TweetBotWeb
34 |
35 | # Import convenience functions from controllers
36 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
37 |
38 | # Use all HTML functionality (forms, tags, etc)
39 | use Phoenix.HTML
40 |
41 | # import TweetBotWeb.Router.Helpers
42 | alias TweetBotWeb.Router.Helpers, as: Routes
43 | import TweetBotWeb.ErrorHelpers
44 | import TweetBotWeb.Gettext
45 | end
46 | end
47 |
48 | def router do
49 | quote do
50 | use Phoenix.Router
51 | import Plug.Conn
52 | import Phoenix.Controller
53 | end
54 | end
55 |
56 | def channel do
57 | quote do
58 | use Phoenix.Channel
59 | import TweetBotWeb.Gettext
60 | end
61 | end
62 |
63 | @doc """
64 | When used, dispatch to the appropriate controller/view/etc.
65 | """
66 | defmacro __using__(which) when is_atom(which) do
67 | apply(__MODULE__, which, [])
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", TweetBotWeb.RoomChannel
6 |
7 | ## Transports
8 | # transport :websocket, Phoenix.Transports.WebSocket
9 | # transport :longpoll, Phoenix.Transports.LongPoll
10 |
11 | # Socket params are passed from the client and can
12 | # be used to verify and authenticate a user. After
13 | # verification, you can put default assigns into
14 | # the socket that will be set for all channels, ie
15 | #
16 | # {:ok, assign(socket, :user_id, verified_user_id)}
17 | #
18 | # To deny connection, return `:error`.
19 | #
20 | # See `Phoenix.Token` documentation for examples in
21 | # performing token verification on connect.
22 | def connect(_params, socket) do
23 | {:ok, socket}
24 | end
25 |
26 | # Socket id's are topics that allow you to identify all sockets for a given user:
27 | #
28 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
29 | #
30 | # Would allow you to broadcast a "disconnect" event and terminate
31 | # all active sockets and channels for a given user:
32 | #
33 | # TweetBotWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
34 | #
35 | # Returning `nil` makes this socket anonymous.
36 | def id(_socket), do: nil
37 | end
38 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/controllers/auth_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.AuthController do
2 | use TweetBotWeb, :controller
3 | alias TweetBot.Accounts
4 |
5 | def callback(conn, %{
6 | "from_id" => from_id,
7 | "oauth_token" => oauth_token,
8 | "oauth_verifier" => oauth_verifier
9 | }) do
10 | # 获取 access token
11 | case ExTwitter.access_token(oauth_verifier, oauth_token) do
12 | {:ok, token} ->
13 | case Accounts.get_user_by_from_id(from_id) do
14 | user when not is_nil(user) ->
15 | case Accounts.update_user(user, %{
16 | access_token: token.oauth_token,
17 | access_token_secret: token.oauth_token_secret
18 | }) do
19 | {:ok, _user} -> text(conn, "授权成功,请关闭此页面")
20 | {:error, _changeset} -> text(conn, "授权失败。")
21 | end
22 |
23 | nil ->
24 | case Accounts.create_user(%{
25 | from_id: from_id,
26 | access_token: token.oauth_token,
27 | access_token_secret: token.oauth_token_secret
28 | }) do
29 | {:ok, _} -> text(conn, "授权成功,请关闭此页面")
30 | {:error, _changeset} -> text(conn, "授权失败。")
31 | end
32 | end
33 |
34 | {:error, reason} ->
35 | text(conn, "授权失败:#{reason}")
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/controllers/fallback_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule FallbackController do
2 | use Phoenix.Controller
3 |
4 | def call(conn, {:error, {_, reason}}) do
5 | json(conn, %{
6 | "method" => "sendMessage",
7 | "chat_id" => conn.assigns.current_user,
8 | "text" => reason
9 | })
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.PageController do
2 | use TweetBotWeb, :controller
3 |
4 | def index(conn, _params) do
5 | render conn, "index.html"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/controllers/twitter.ex:
--------------------------------------------------------------------------------
1 | defmodule Twitter do
2 | @callback request_token(String.t()) :: map()
3 | @callback authenticate_url(String.t()) :: {:ok, String.t()} | {:error, String.t()}
4 | end
5 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/controllers/twitter_api.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterAPI do
2 | @behaviour Twitter
3 |
4 | def request_token(redirect_url \\ nil) do
5 | ExTwitter.request_token(redirect_url)
6 | end
7 |
8 | def authenticate_url(token) do
9 | ExTwitter.authenticate_url(token)
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/controllers/twitter_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.TwitterController do
2 | use TweetBotWeb, :controller
3 |
4 | import TelegramBot
5 | alias TweetBot.Accounts
6 | plug(:find_user)
7 | plug(:configure_extwitter)
8 | action_fallback(FallbackController)
9 |
10 | @twitter_api Application.get_env(:tweet_bot, :twitter_api)
11 |
12 | defp find_user(conn, _) do
13 | %{"message" => %{"from" => %{"id" => from_id}}} = conn.params
14 |
15 | case Accounts.get_user_by_from_id(from_id) do
16 | user when not is_nil(user) ->
17 | assign(conn, :current_user, user.from_id)
18 |
19 | nil ->
20 | get_twitter_oauth(conn, from_id) |> halt()
21 | end
22 | end
23 |
24 | defp configure_extwitter(conn, _) do
25 | # 读取用户 token
26 | user = Accounts.get_user_by_from_id!(conn.assigns.current_user)
27 |
28 | ExTwitter.configure(
29 | :process,
30 | Enum.concat(
31 | ExTwitter.Config.get_tuples(),
32 | access_token: user.access_token,
33 | access_token_secret: user.access_token_secret
34 | )
35 | )
36 |
37 | conn
38 | end
39 |
40 | def index(conn, %{"message" => %{"text" => "/start"}}) do
41 | try do
42 | ExTwitter.verify_credentials()
43 |
44 | json(conn, %{
45 | "method" => "sendMessage",
46 | "text" => "已授权,请直接发送消息",
47 | "chat_id" => conn.assigns.current_user
48 | })
49 | rescue
50 | _ ->
51 | %{"message" => %{"from" => %{"id" => from_id}}} = conn.params
52 |
53 | get_twitter_oauth(conn, from_id) |> halt()
54 | end
55 | end
56 |
57 | def index(conn, %{"message" => %{"text" => "/z"}}) do
58 | [latest_tweet | _] = ExTwitter.user_timeline(count: 1)
59 | ExTwitter.destroy_status(latest_tweet.id)
60 |
61 | json(conn, %{
62 | "method" => "sendMessage",
63 | "text" => "撤销成功",
64 | "chat_id" => conn.assigns.current_user
65 | })
66 | end
67 |
68 | def index(conn, %{"message" => %{"photo" => photo} = message}) do
69 | caption = Map.get(message, "caption", "")
70 |
71 | case getFile(photo |> Enum.at(-1) |> Map.get("file_id")) do
72 | {:ok, file} ->
73 | tweet_photo(conn, file, caption)
74 | end
75 | end
76 |
77 | # 处理 file 形式的图片
78 | def index(conn, %{
79 | "message" => %{"document" => %{"mime_type" => mime_type} = document} = message
80 | })
81 | when mime_type in ["image/png", "image/jpeg", "image/gif"] do
82 | caption = Map.get(message, "caption", "")
83 |
84 | case getFile(Map.get(document, "file_id")) do
85 | {:ok, file} ->
86 | tweet_photo(conn, file, caption)
87 | end
88 | end
89 |
90 | def index(conn, %{"message" => %{"text" => text}}) do
91 | try do
92 | ExTwitter.update(text)
93 | json(conn, %{})
94 | rescue
95 | e in ExTwitter.Error ->
96 | {:error, {:extwitter, e.message}}
97 | end
98 | end
99 |
100 | defp get_twitter_oauth(conn, from_id) do
101 | token =
102 | @twitter_api.request_token(
103 | URI.encode_www_form(
104 | TweetBotWeb.Router.Helpers.auth_url(conn, :callback) <> "?from_id=#{from_id}"
105 | )
106 | )
107 |
108 | {:ok, authenticate_url} = @twitter_api.authenticate_url(token.oauth_token)
109 |
110 | conn
111 | |> json(%{
112 | "method" => "sendMessage",
113 | "chat_id" => from_id,
114 | "text" => "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter",
115 | "parse_mode" => "HTML"
116 | })
117 | end
118 |
119 | defp tweet_photo(conn, file, caption) do
120 | try do
121 | %HTTPoison.Response{body: body} =
122 | HTTPoison.get!(
123 | "https://api.telegram.org/file/bot#{Application.get_env(:telegram_bot, :token)}/#{
124 | file |> Map.get("file_path")
125 | }",
126 | []
127 | )
128 |
129 | ExTwitter.update_with_media(caption, body)
130 | json(conn, %{})
131 | rescue
132 | e in ExTwitter.Error ->
133 | {:error, {:extwitter, e.message}}
134 |
135 | e in HTTPoison.Error ->
136 | {:error, {:httpoison, e.reason}}
137 | end
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :tweet_bot
3 |
4 | socket "/socket", TweetBotWeb.UserSocket,
5 | websocket: true,
6 | longpoll: false
7 |
8 | # Serve at "/" the static files from "priv/static" directory.
9 | #
10 | # You should set gzip to true if you are running phoenix.digest
11 | # when deploying your static files in production.
12 | plug Plug.Static,
13 | at: "/", from: :tweet_bot, gzip: false,
14 | only: ~w(css fonts images js favicon.ico robots.txt docs)
15 |
16 | # Code reloading can be explicitly enabled under the
17 | # :code_reloader configuration of your endpoint.
18 | if code_reloading? do
19 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
20 | plug Phoenix.LiveReloader
21 | plug Phoenix.CodeReloader
22 | end
23 |
24 | plug Plug.Logger
25 |
26 | plug Plug.Parsers,
27 | parsers: [:urlencoded, :multipart, :json],
28 | pass: ["*/*"],
29 | json_decoder: Jason
30 |
31 | plug Plug.MethodOverride
32 | plug Plug.Head
33 |
34 | # The session will be stored in the cookie and signed,
35 | # this means its contents can be read but not tampered with.
36 | # Set :encryption_salt if you would also like to encrypt it.
37 | plug Plug.Session,
38 | store: :cookie,
39 | key: "_tweet_bot_key",
40 | signing_salt: "0rkCFrFM"
41 |
42 | plug TweetBotWeb.Router
43 |
44 | @doc """
45 | Callback invoked for dynamically configuring the endpoint.
46 |
47 | It receives the endpoint configuration and checks if
48 | configuration should be loaded from the system environment.
49 | """
50 | def init(_key, config) do
51 | if config[:load_from_system_env] do
52 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set"
53 | {:ok, Keyword.put(config, :http, [:inet6, port: port])}
54 | else
55 | {:ok, config}
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import TweetBotWeb.Gettext
9 |
10 | # Simple translation
11 | gettext "Here is the string to translate"
12 |
13 | # Plural translation
14 | ngettext "Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3
17 |
18 | # Domain-based translation
19 | dgettext "errors", "Here is the error message to translate"
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :tweet_bot
24 | end
25 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.Router do
2 | use TweetBotWeb, :router
3 |
4 | pipeline :browser do
5 | plug(:accepts, ["html"])
6 | plug(:fetch_session)
7 | plug(:fetch_flash)
8 | plug(:protect_from_forgery)
9 | plug(:put_secure_browser_headers)
10 | end
11 |
12 | pipeline :api do
13 | plug(:accepts, ["json"])
14 | end
15 |
16 | scope "/", TweetBotWeb do
17 | # Use the default browser stack
18 | pipe_through(:browser)
19 |
20 | get("/", PageController, :index)
21 | get("/auth_callback", AuthController, :callback)
22 | end
23 |
24 | # Other scopes may use custom stacks.
25 | scope "/api", TweetBotWeb do
26 | pipe_through(:api)
27 | post("/twitter", TwitterController, :index)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Hello Tweet for me bot!
11 | ">
12 |
13 |
14 |
15 |
35 |
36 | <%= render @view_module, @view_template, assigns %>
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/templates/page/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
Telegram bot for tweeting only
3 |
/z to delete your latest tweet
4 |
5 |
6 |
7 |
8 |
Supported
9 |
10 | -
11 | Text
12 |
13 | -
14 | Photo, with or without caption
15 |
16 |
17 |
18 |
19 |
20 |
Not Supported
21 |
22 | -
23 | Video
24 |
25 | -
26 | Album
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn (error) ->
13 | content_tag :span, translate_error(error), class: "help-block"
14 | end)
15 | end
16 |
17 | @doc """
18 | Translates an error message using gettext.
19 | """
20 | def translate_error({msg, opts}) do
21 | # When using gettext, we typically pass the strings we want
22 | # to translate as a static argument:
23 | #
24 | # # Translate "is invalid" in the "errors" domain
25 | # dgettext "errors", "is invalid"
26 | #
27 | # # Translate the number of files with plural rules
28 | # dngettext "errors", "1 file", "%{count} files", count
29 | #
30 | # Because the error messages we show in our forms and APIs
31 | # are defined inside Ecto, we need to translate them dynamically.
32 | # This requires us to call the Gettext module passing our gettext
33 | # backend as first argument.
34 | #
35 | # Note we use the "errors" domain, which means translations
36 | # should be written to the errors.po file. The :count option is
37 | # set by Ecto and indicates we should also apply plural rules.
38 | if count = opts[:count] do
39 | Gettext.dngettext(TweetBotWeb.Gettext, "errors", msg, msg, count, opts)
40 | else
41 | Gettext.dgettext(TweetBotWeb.Gettext, "errors", msg, opts)
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.ErrorView do
2 | use TweetBotWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.html", _assigns) do
7 | # "Internal Server Error"
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.html" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | Phoenix.Controller.status_message_from_template(template)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.LayoutView do
2 | use TweetBotWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tweet_bot_web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.PageView do
2 | use TweetBotWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :tweet_bot,
7 | version: "0.0.7",
8 | elixir: "~> 1.4",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | aliases: aliases(),
13 | deps: deps(),
14 | releases: [
15 | tweet_bot: [
16 | include_executables_for: [:unix]
17 | ]
18 | ]
19 | ]
20 | end
21 |
22 | # Configuration for the OTP application.
23 | #
24 | # Type `mix help compile.app` for more information.
25 | def application do
26 | [
27 | mod: {TweetBot.Application, []},
28 | extra_applications: [:logger, :runtime_tools]
29 | ]
30 | end
31 |
32 | # Specifies which paths to compile per environment.
33 | defp elixirc_paths(:test), do: ["lib", "test/support"]
34 | defp elixirc_paths(_), do: ["lib"]
35 |
36 | # Specifies your project dependencies.
37 | #
38 | # Type `mix help deps` for examples and options.
39 | defp deps do
40 | [
41 | {:phoenix, "~> 1.4.0"},
42 | {:phoenix_pubsub, "~> 1.0"},
43 | {:ecto_sql, "~> 3.0"},
44 | {:phoenix_ecto, "~> 4.0"},
45 | {:postgrex, ">= 0.0.0"},
46 | {:phoenix_html, "~> 2.10"},
47 | {:phoenix_live_reload, "~> 1.0", only: :dev},
48 | {:gettext, "~> 0.11"},
49 | {:plug_cowboy, "~> 2.0"},
50 | {:telegram_bot, "~> 1.0.1"},
51 | {:oauther, "~> 1.1"},
52 | {:extwitter, "~> 0.9.3"},
53 |
54 | {:mox, "~> 0.4", only: :test},
55 | {:jason, "~> 1.0"},
56 | {:poison, "~> 3.1"}
57 | ]
58 | end
59 |
60 | # Aliases are shortcuts or tasks specific to the current project.
61 | # For example, to create, migrate and run the seeds file at once:
62 | #
63 | # $ mix ecto.setup
64 | #
65 | # See the documentation for `Mix` for more info on aliases.
66 | defp aliases do
67 | [
68 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
69 | "ecto.reset": ["ecto.drop", "ecto.setup"],
70 | test: ["ecto.create --quiet", "ecto.migrate", "test"]
71 | ]
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "artificery": {:hex, :artificery, "0.2.6", "f602909757263f7897130cbd006b0e40514a541b148d366ad65b89236b93497a", [:mix], [], "hexpm"},
3 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
4 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
5 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
6 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"},
7 | "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
8 | "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
9 | "ecto": {:hex, :ecto, "3.1.7", "fa21d06ef56cdc2fdaa62574e8c3ba34a2751d44ea34c30bc65f0728421043e5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
10 | "ecto_sql": {:hex, :ecto_sql, "3.1.6", "1e80e30d16138a729c717f73dcb938590bcdb3a4502f3012414d0cbb261045d8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0 or ~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
11 | "extwitter": {:hex, :extwitter, "0.9.3", "7c93305bd6928bfc3566f91d751275c9408701031e76ae9f250711d4a5e1a6a0", [:mix], [{:oauther, "~> 1.1", [hex: :oauther, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
12 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"},
13 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"},
14 | "hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
15 | "httpoison": {:hex, :httpoison, "1.1.1", "96ed7ab79f78a31081bb523eefec205fd2900a02cda6dbc2300e7a1226219566", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
16 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
17 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
19 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
20 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
21 | "mox": {:hex, :mox, "0.4.0", "7f120840f7d626184a3d65de36189ca6f37d432e5d63acd80045198e4c5f7e6e", [:mix], [], "hexpm"},
22 | "oauther": {:hex, :oauther, "1.1.1", "7d8b16167bb587ecbcddd3f8792beb9ec3e7b65c1f8ebd86b8dd25318d535752", [:mix], [], "hexpm"},
23 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
24 | "phoenix": {:hex, :phoenix, "1.4.0", "56fe9a809e0e735f3e3b9b31c1b749d4b436e466d8da627b8d82f90eaae714d2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
25 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
26 | "phoenix_html": {:hex, :phoenix_html, "2.12.0", "1fb3c2e48b4b66d75564d8d63df6d53655469216d6b553e7e14ced2b46f97622", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
27 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"},
29 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"},
30 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.0", "ab0c92728f2ba43c544cce85f0f220d8d30fc0c90eaa1e6203683ab039655062", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
31 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
32 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
33 | "postgrex": {:hex, :postgrex, "0.15.0", "dd5349161019caeea93efa42f9b22f9d79995c3a86bdffb796427b4c9863b0f0", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
34 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
35 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
36 | "telegram_bot": {:hex, :telegram_bot, "1.0.1", "f07b009447c455b9f3e31035d09bd7f8d6fc45074e4d4266a9fad03e6a26e624", [:mix], [{:httpoison, "~> 1.1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
37 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
38 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
39 | }
40 |
--------------------------------------------------------------------------------
/notes/add-routes.asc:
--------------------------------------------------------------------------------
1 | = 路由
2 | 陈三
3 | :!webfonts:
4 | :source-highlighter: pygments
5 |
6 | 打开 `lib/tweet_bot_web/router.ex` 文件,添加上一节中我们尚未创建的路由:
7 |
8 | .lib/tweet_bot_web/router.ex
9 | [source,elixir]
10 | ----
11 | scope "/api", TweetBotWeb do
12 | pipe_through(:api)
13 | + post("/twitter", TwitterController, :index)
14 | end
15 | ----
16 | 接着在 `lib/tweet_bot_web/controllers` 目录下新建控制器 `twitter_controller.ex`:
17 |
18 | .lib/tweet_bot_web/controllers/twitter_controller.ex
19 | [source,elixir]
20 | ----
21 | defmodule TweetBotWeb.TwitterController do
22 | use TweetBotWeb, :controller
23 |
24 | def index(conn, _params) do
25 | json(conn, %{})
26 | end
27 | end
28 | ----
29 | 我们暂时先响应一个 json 空对象。
30 |
31 | == TwitterController
32 |
33 | 在 `index` 动作里,我们要提取 telegram 用户的 id 及消息内容:
34 |
35 | ```elixir
36 | def index(conn, %{"message" => %{"from" => %{"id" => from_id}, "text" => text}}) do
37 | json(conn, %{})
38 | end
39 | ```
40 | 这里利用 https://elixir-lang.org/getting-started/pattern-matching.html[模式匹配]提取用户 `id` 及 `text` 内容。
41 |
42 | 我们第一个要处理的 `text` 将是 `/start` - 用户添加 telegram 发推机器人时,telegram 客户端会自动发起该消息。
43 |
44 | 而 `index` 在接收到 `/start` 后,要做几个判断:
45 |
46 | . 检查数据库中是否已经存在该 `from_id`,如果没有,表示用户未授权,此时应启动 twitter 的 OAuth 流程;
47 | . 如果数据库中存在该 `from_id`,说明用户已授权 - 提示用户直接发送消息。
48 |
49 | 也就是说,我们在数据库中要存储 `from_id` 数据,此外还要存储用户授权后从 twitter 获得的 `access_token`。
50 |
51 | 那么要手写 Scheme 吗?当然不,用 https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Context.html#content[mix tasks] 吧:
52 |
53 | ```sh
54 | $ mix phx.gen.context Accounts User users from_id:string:unique access_token:string
55 | * creating lib/tweet_bot/accounts/user.ex
56 | * creating priv/repo/migrations/20181203113027_create_users.exs
57 | * creating lib/tweet_bot/accounts/accounts.ex
58 | * injecting lib/tweet_bot/accounts/accounts.ex
59 | * creating test/tweet_bot/accounts/accounts_test.exs
60 | * injecting test/tweet_bot/accounts/accounts_test.exs
61 |
62 | Remember to update your repository by running migrations:
63 |
64 | $ mix ecto.migrate
65 | ```
66 | 这样我们就新建了一个 `Accounts` 上下文,以及 `User` 结构。
67 |
68 | 打开 `priv/repo/migrations/20181203113027_create_users.exs` 文件,调整 `from_id`:
69 |
70 | .priv/repo/migrations/20181203113027_create_users.exs
71 | ```exs
72 | - field :from_id, :string
73 | + field :from_id, :string, null: false
74 | ```
75 |
76 | 接着再运行 `mix ecto.migrate` 创建 `users` 表:
77 |
78 | ```sh
79 | $ mix ecto.migrate
80 | [info] == Running TweetBot.Repo.Migrations.CreateUsers.change/0 forward
81 | [info] create table users
82 | [info] create index users_from_id_index
83 | [info] == Migrated in 0.0s
84 | ```
85 |
86 |
--------------------------------------------------------------------------------
/notes/delete-tweet.asc:
--------------------------------------------------------------------------------
1 | = 删推
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 人总会有犯错的时候,比如把不该发的内容发到 twitter,所以我决定给发推机器人加上删推功能。
8 |
9 | 与 `/start` 命令类似,这个删推的命令是 `/z`。
10 |
11 | 打开 `twitter_controller.ex`,新增代码如下:
12 |
13 | ```elixir
14 | def index(conn, %{"message" => %{"text" => "/z"}}) do
15 | # 读取用户 token
16 | user = Accounts.get_user_by_from_id!(conn.assigns.current_user)
17 |
18 | ExTwitter.configure(
19 | :process,
20 | Enum.concat(
21 | ExTwitter.Config.get_tuples(),
22 | access_token: user.access_token,
23 | access_token_secret: user.access_token_secret
24 | )
25 | )
26 |
27 | [latest_tweet | _] = ExTwitter.user_timeline(count: 1)
28 | ExTwitter.destroy_status(latest_tweet.id)
29 | sendMessage(conn.assigns.current_user, "撤销成功")
30 | json(conn, %{})
31 | end
32 | ```
33 | 不过,`ExTwitter.configure` 代码在两处 `index` 中重复出现,我们可以将它提取到 plug 中:
34 |
35 | ```elixir
36 | defp configure_extwitter(conn, _) do
37 | # 读取用户 token
38 | user = Accounts.get_user_by_from_id!(conn.assigns.current_user)
39 |
40 | ExTwitter.configure(
41 | :process,
42 | Enum.concat(
43 | ExTwitter.Config.get_tuples(),
44 | access_token: user.access_token,
45 | access_token_secret: user.access_token_secret
46 | )
47 | )
48 |
49 | conn
50 | end
51 | ```
52 | 最后在 `twitter_controller.ex` 头部调用 plug:
53 |
54 | ```elixir
55 | import TelegramBot
56 | alias TweetBot.Accounts
57 | plug(:find_user)
58 | + plug(:configure_extwitter)
59 | ```
60 | 不过,这个方案是有缺陷的。拿 `def index(conn, %{"message" => %{"text" => "/start"}}) do` 来说,它并不与 twitter api 通信,也就没必要执行 `ExTwitter.configure`,而在我们新增的 plug 下,`ExTwitter.configure` 是一定会执行的。
--------------------------------------------------------------------------------
/notes/demon-in-details.asc:
--------------------------------------------------------------------------------
1 | = 细节中藏着魔鬼
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 我们还有许多细节尚未考虑,下列罗列我现在想到的几点。
8 |
9 | == 字符限制
10 |
11 | 我们知道 twitter 曾经有 140 个字符的限制,后面陆陆续续又开放了更多的字符。
12 |
13 | 所以,用户发送的消息超过 https://developer.twitter.com/en/docs/basics/counting-characters[twitter 字符限制]时,我们要怎么办?
14 |
15 | 是在服务器端就做判断然后提示用户呢?还是不管不问假装没看到直接提交给 twitter api 由它来判断,我们只负责传话呢?
16 |
17 | 我稍稍验证了下,目前中文限制是 140 个字符,英文是 280 个字符,其它语言的情况可能还更复杂。所以直接把用户消息提交给 twitter api 来判定是比较靠谱、也简单的。我们只需要把 twitter api 的错误响应返回给用户即可。
18 |
19 | ```elixir
20 | -
21 | - ExTwitter.update(text)
22 | + try do
23 | + ExTwitter.update(text)
24 | + rescue
25 | + e in ExTwitter.Error -> sendMessage(conn.assigns.current_user, "#{e.message}")
26 | + end
27 | json(conn, %{})
28 | ```
29 | 至于成功发推的情况,就不回消息给用户了 - no news is good news。
30 |
31 | == 图片等
32 |
33 | 除了文字外,telegram 还可以发送图片、文件、视频、音频,等等内容。
34 |
35 | 目前,我们还只处理了文本。其它类型要怎么办?提示用户?还是不管?
36 |
37 | 提示用户的话,我们很可能要撞上 telegram 的 https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this[api 限制]。实际上,前面在超出 twitter 字符限制时,我们选择了提示用户,就已经有可能撞上 telegram 的 api 限制。所以应尽量避免给用户回复消息。
38 |
39 | 至于图片、视频、音频等程序未处理的类型,我们也不必担心,因为 Erlang 系统非常稳健。
40 |
41 | 但我还是决定加入图片支持 - 毕竟,我挺经常在 twitter 上发图片。
42 |
--------------------------------------------------------------------------------
/notes/deploy.asc:
--------------------------------------------------------------------------------
1 | = 部署 Phoenix Framework
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 据我所知,Phoenix 项目的部署方案有俩种:
8 |
9 | 1. https://hexdocs.pm/phoenix/deployment.html#content[Phoenix 文档]中介绍的,将源代码推送到生产环境,安装依赖后运行 `MIX_ENV=prod mix phx.server`
10 | 2. 使用 https://github.com/bitwalker/distillery[`distillery`] 构建 Erlang/OTP 发行包,然后部署发行包
11 |
12 | 第一种方案直观、简单,与开发环境的体验一致。然而第二种方案才是我们应该使用的部署方案,因为能够享有 OTP 的一切好处,但过程并不简单,至少目前是这样。
13 |
14 | 这里聊的是第二种方案。
15 |
16 | == 安装 distillery
17 |
18 | 在 `mix.exs` 文件中新增 `distillery` 依赖如下:
19 |
20 | ```elixir
21 | + {:distillery, "~> 2.0"},
22 | ]
23 | end
24 | ```
25 |
26 | 然后运行 `mix deps.get` 安装 `distillery`。
27 |
28 | 安装完 `distillery` 后,运行 `mix release.init` 来初始化构建:
29 |
30 | ```sh
31 | $ mix release.init
32 | ...
33 | An example config file has been placed in rel/config.exs, review it,
34 | make edits as needed/desired, and then run `mix release` to build the release
35 | ```
36 |
37 | `mix release.init` 命令在 `rel` 目录下生成 `config.exs` 文件,稍后我们要做些调整。
38 |
39 | == 配置 prod.exs
40 |
41 | 我们曾在 `dev.exs` 里新增过 `telegram_bot` 的 `token`:
42 |
43 | ```elixir
44 | # Configures token for telegram bot
45 | config :telegram_bot,
46 | token: System.get_env("TELEGRAM_TOKEN")
47 | ```
48 |
49 | 同样地,我们需要在 `prod.exs` 文件中新增:
50 |
51 | ```elixir
52 | # Configures token for telegram bot
53 | config :telegram_bot,
54 | token: System.get_env("TELEGRAM_TOKEN")
55 | ```
56 |
57 | 为什么不是在 `prod.secret.exs` 里新增?这是因为 `prod.secret.exs` 里存储的是明文的隐私内容,而 `System.get_env("TELEGRAM_TOKEN")` 并非隐私内容,就没必要放入 `prod.secret.exs` 里。
58 |
59 | 此外,我们还需要在 `prod.exs` 里配置 twitter 的 `consumer_key`、`consumer_secret`:
60 |
61 | ```elixir
62 | # Configures extwitter oauth
63 | config :extwitter, :oauth,
64 | consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
65 | consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
66 | ```
67 |
68 | 至于 `prod.secret.exs` 中的其它配置,我们均调整为从环境变量中读取:
69 |
70 | ```elixir
71 | config :tweet_bot, TweetBotWeb.Endpoint, secret_key_base: System.get_env("SECRET_KEY_BASE")
72 |
73 | # Configure your database
74 | config :tweet_bot, TweetBot.Repo,
75 | adapter: Ecto.Adapters.Postgres,
76 | username: System.get_env("DATABASE_USER"),
77 | password: System.get_env("DATABASE_PASS"),
78 | database: System.get_env("DATABASE_NAME"),
79 | hostname: System.get_env("DATABASE_HOST"),
80 | pool_size: 15
81 | ```
82 |
83 | 不过这样的话,`prod.secret.exs` 就没有存在意义了,因此,我们将它的内容迁移至 `prod.exs` 中,并且删掉 `prod.exs` 的最末几行:
84 |
85 | ```elixir
86 | - # Finally import the config/prod.secret.exs
87 | - # which should be versioned separately.
88 | - import_config "prod.secret.exs"
89 | ```
90 |
91 | 但我们有一个新问题,distillery 在构建时,`System.get_env("TELEGRAM_TOKEN")` 这样的动态取值变会成静态的 - 即哪儿构建,哪儿取值,而不是我们预想的从生产环境中动态读取。
92 |
93 | Distillery 从 2.0 版本开始,提供了 https://hexdocs.pm/distillery/config/runtime.html#config-providers[Config providers] 来解决这个问题。Config providers 能够在发行包启动前动态读取配置,并将结果推送入应用环境中。
94 |
95 | 怎么用?很简单,我们前面运行 `mix release.init` 时,根目录下生成了 `rel/config.exs` 文件,其中有 `release :tweet_bot do` 一段代码,我们在函数中新增如下代码:
96 |
97 | ```elixir
98 | set(
99 | config_providers: [
100 | {Mix.Releases.Config.Providers.Elixir, ["${RELEASE_ROOT_DIR}/etc/config.exs"]}
101 | ]
102 | )
103 |
104 | set(
105 | overlays: [
106 | {:copy, "config/prod.exs", "etc/config.exs"}
107 | ]
108 | )
109 | ```
110 |
111 | `overlays` 表示将 `config` 目录下的 `prod.exs` 拷贝至 `etc/config.exs` 位置,而 `config_providers` 则指定 Config providers 从何处读取配置。
112 |
113 | 此外,我们还需要针对 Phoenix https://hexdocs.pm/distillery/guides/phoenix_walkthrough.html[调整 `prod.exs` 里的一些配置]:
114 |
115 | ```elixir
116 | config :tweet_bot, TweetBotWeb.Endpoint,
117 | - load_from_system_env: true,
118 | - url: [host: "example.com", port: 80],
119 | - cache_static_manifest: "priv/static/cache_manifest.json"
120 | + http: [port: {:system, "PORT"}],
121 | + url: [host: "localhost", port: {:system, "PORT"}],
122 | + cache_static_manifest: "priv/static/cache_manifest.json",
123 | + server: true,
124 | + root: ".",
125 | + version: Application.spec(:tweet_bot, :vsn)
126 | ```
127 |
128 | 注意,应用绑定的端口同样是从生产环境变量 `PORT` 中读取。
129 |
130 | == 初始化数据库
131 |
132 | 我们在开发环境中可以执行 `mix ecto.create` 来创建数据库,并通过 `mix ecto.migrate` 来初始化数据库表,但 distillery 构建后,mix 不再存在,所以开发环境中可行的方案都不再可行。
133 |
134 | distillery 另有方案来 https://hexdocs.pm/distillery/guides/running_migrations.html[初始化数据库及数据库表]。
135 |
136 | 在 `lib` 目录下新建一个 `release_tasks.ex` 文件,内容如下:
137 |
138 | ```elixir
139 | defmodule TweetBot.ReleaseTasks do
140 | @start_apps [
141 | :crypto,
142 | :ssl,
143 | :postgrex,
144 | :ecto,
145 | :ecto_sql
146 | ]
147 |
148 | @repos Application.get_env(:tweet_bot, :ecto_repos, [])
149 |
150 | def migrate(_argv) do
151 | start_services()
152 |
153 | run_migrations()
154 |
155 | stop_services()
156 | end
157 |
158 | def seed(_argv) do
159 | start_services()
160 |
161 | run_migrations()
162 |
163 | run_seeds()
164 |
165 | stop_services()
166 | end
167 |
168 | defp start_services do
169 | IO.puts("Starting dependencies..")
170 | # Start apps necessary for executing migrations
171 | Enum.each(@start_apps, &Application.ensure_all_started/1)
172 |
173 | # Start the Repo(s) for app
174 | IO.puts("Starting repos..")
175 | Enum.each(@repos, & &1.start_link(pool_size: 2))
176 | end
177 |
178 | defp stop_services do
179 | IO.puts("Success!")
180 | :init.stop()
181 | end
182 |
183 | defp run_migrations do
184 | Enum.each(@repos, &run_migrations_for/1)
185 | end
186 |
187 | defp run_migrations_for(repo) do
188 | app = Keyword.get(repo.config, :otp_app)
189 | IO.puts("Running migrations for #{app}")
190 | migrations_path = priv_path_for(repo, "migrations")
191 | Ecto.Migrator.run(repo, migrations_path, :up, all: true)
192 | end
193 |
194 | defp run_seeds do
195 | Enum.each(@repos, &run_seeds_for/1)
196 | end
197 |
198 | defp run_seeds_for(repo) do
199 | # Run the seed script if it exists
200 | seed_script = priv_path_for(repo, "seeds.exs")
201 |
202 | if File.exists?(seed_script) do
203 | IO.puts("Running seed script..")
204 | Code.eval_file(seed_script)
205 | end
206 | end
207 |
208 | defp priv_path_for(repo, filename) do
209 | app = Keyword.get(repo.config, :otp_app)
210 |
211 | repo_underscore =
212 | repo
213 | |> Module.split()
214 | |> List.last()
215 | |> Macro.underscore()
216 |
217 | priv_dir = "#{:code.priv_dir(app)}"
218 |
219 | Path.join([priv_dir, repo_underscore, filename])
220 | end
221 | end
222 | ```
223 |
224 | 然后在 `rel/commands` 目录下新建 `migrate.sh`:
225 |
226 | ```sh
227 | #!/bin/sh
228 |
229 | release_ctl eval --mfa "TweetBot.ReleaseTasks.migrate/1" --argv -- "$@"
230 | ```
231 |
232 | `"$@"` 表示将命令行参数全部传递给 `TweetBot.ReleaseTasks.migrate/1` 函数。
233 |
234 | 再新建一个 `seed.sh` 文件:
235 |
236 | ```sh
237 | #!/bin/sh
238 |
239 | release_ctl eval --mfa "TweetBot.ReleaseTasks.seed/1" --argv -- "$@"
240 | ```
241 |
242 | 最后调整 `rel/config.exs`,新增 `commands`:
243 |
244 | ```elixir
245 | release :tweet_bot do
246 | + set commands: [
247 | + migrate: "rel/commands/migrate.sh",
248 | + seed: "rel/commands/seed.sh"
249 | + ]
250 | end
251 | ```
252 |
253 | 这样我们在应用部署到生产环境后,就可以执行 `bin/tweet_bot migrate` 来初始化数据库表,`bin/tweet_bot seed` 来填充数据。
254 |
255 | 但我希望 migrate 与 seed 过程能够自动化,而不是启动应用后手动执行。Distillery 提供了 hook 来解决这个问题。
256 |
257 | 在 `rel/hooks` 目录下新建 `pre_start` 目录,并在 `pre_start` 目录下创建一个 `prepare` 文件,内容如下:
258 |
259 | ```sh
260 | $RELEASE_ROOT_DIR/bin/tweet_bot migrate
261 |
262 | $RELEASE_ROOT_DIR/bin/tweet_bot seed
263 | ```
264 |
265 | 再次调整 `rel/config.exs` 文件,新增:
266 |
267 | ```elixir
268 | set(
269 | overlays: [
270 | {:copy, "config/prod.exs", "etc/config.exs"}
271 | ]
272 | )
273 | + set(pre_start_hooks: "rel/hooks/pre_start")
274 | ```
275 | 这样应用在启动前会自动执行 migrate 与 seed 命令。
276 |
277 | == 构建
278 |
279 | 在完成以上配置后,我们终于可以开始构建 Phoenix 程序。
280 |
281 | 运行 `MIX_ENV=prod mix release` 试试:
282 |
283 | ```sh
284 | $ MIX_ENV=prod mix release
285 | ==> Assembling release..
286 | ==> Building release tweet_bot:0.0.1 using environment prod
287 | ==> Including ERTS 10.0.8 from /usr/local/Cellar/erlang/21.0.9/lib/erlang/erts-10.0.8
288 | ==> Packaging release..
289 | Release successfully built!
290 | To start the release you have built, you can use one of the following tasks:
291 |
292 | # start a shell, like 'iex -S mix'
293 | > _build/prod/rel/tweet_bot/bin/tweet_bot console
294 |
295 | # start in the foreground, like 'mix run --no-halt'
296 | > _build/prod/rel/tweet_bot/bin/tweet_bot foreground
297 |
298 | # start in the background, must be stopped with the 'stop' command
299 | > _build/prod/rel/tweet_bot/bin/tweet_bot start
300 |
301 | If you started a release elsewhere, and wish to connect to it:
302 |
303 | # connects a local shell to the running node
304 | > _build/prod/rel/tweet_bot/bin/tweet_bot remote_console
305 |
306 | # connects directly to the running node's console
307 | > _build/prod/rel/tweet_bot/bin/tweet_bot attach
308 |
309 | For a complete listing of commands and their use:
310 |
311 | > _build/prod/rel/tweet_bot/bin/tweet_bot help
312 | ```
313 |
314 | 构建成功。在设置好必需的环境变量后运行 `_build/prod/rel/tweet_bot/bin/tweet_bot console` 也没有问题。
315 |
316 | 但这只是本地构建。我在 macOS 系统上构建的发行包不能运行在生产环境系统中(Linux),因为不同系统下 Erlang 运行时(Erlang Runtime System)不一样。
317 |
318 | 我们有三种方案:
319 |
320 | 1. 本地构建时设定 `include_erts: false`,发行包里不再打包 ERTS,由生产环境自行安装 ERTS
321 | 2. 在本地交叉编译面向生产环境的 ERTS,并在构建时设定 `include_erts: "path/to/cross/compiled/erts"`
322 | 3. 在与生产环境类似的构建环境中构建发行包
323 |
324 | 我倾向于第 3 种方案。我可以新建一台服务器专门用于构建 - 但还有一个我看来更为简便、也更节省的方案:在 Docker 中构建。
325 |
326 | === Docker 中构建 Phoenix 应用
327 |
328 | 因为我的程序最终将部署到 Ubuntu 16.04 系统,所以我需要准备一个基于 Ubuntu 16.04 的 https://hub.docker.com/r/chenxsan/elixir-ubuntu/[docker image],其中已安装好 Erlang 及 Elixir 等构建 Phoenix 所需的依赖。
329 |
330 | 参考 https://hexdocs.pm/distillery/guides/building_in_docker.html#building-releases[Distillery 文档]在项目根目录新建一个 `bin` 文件夹,并在 `bin` 目录下新建 `build.sh` 文件,注意要执行 `chmod +x bin/build.sh` 让它可执行:
331 |
332 | ```sh
333 | #!/usr/bin/env bash
334 |
335 | set -e
336 |
337 | cd /opt/build/app
338 |
339 | APP_NAME="$(grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g')"
340 | APP_VSN="$(grep 'version:' mix.exs | cut -d '"' -f2)"
341 |
342 | mkdir -p /opt/build/app/rel/artifacts
343 |
344 | export MIX_ENV=prod
345 |
346 | # Fetch deps and compile
347 | mix deps.get --only prod
348 | # Run an explicit clean to remove any build artifacts from the host
349 | mix do clean, compile --force
350 | cd ./assets
351 | npm install
352 | npm run deploy
353 | cd ..
354 | mix phx.digest
355 | # Build the release
356 | mix release --env=prod
357 | # Copy tarball to output
358 | cp "_build/prod/rel/$APP_NAME/releases/$APP_VSN/$APP_NAME.tar.gz" rel/artifacts/"$APP_NAME-$APP_VSN.tar.gz"
359 |
360 | exit 0
361 | ```
362 |
363 | 之后运行:
364 |
365 | ```sh
366 | $ docker run -v $(pwd):/opt/build/app --rm -it chenxsan/elixir-ubuntu:latest /opt/build/app/bin/build.sh
367 | ```
368 |
369 | 之后我们就得到 `tweet_bot.tar.gz` 压缩包。
370 |
371 | 接下来是部署 `tweet_bot.tar.gz`。
372 |
373 | == 搭建生产环境
374 |
375 | 我们可借助 Terraform、Ansible 一类运维工具准备生产环境,但这里不打算谈这类工具的使用,因为会增加笔记的复杂度。
376 |
377 | 我们创建一台安装了 Ubuntu 16.04 的服务器,然后在服务器上安装 https://caddyserver.com[Caddy]:
378 |
379 | ```sh
380 | $ curl https://getcaddy.com | bash -s personal http.ipfilter,http.ratelimit
381 | ```
382 |
383 | 之所以选择 Caddy 而不是 Nginx、Apache,是因为我不想折腾 Let's Encrypt。
384 |
385 | == 启动
386 |
387 | 在启动程序前,我们需要事先创建生产环境数据库,并且配置以下环境变量:
388 |
389 | 1. PORT
390 | 2. TELEGRAM_TOKEN
391 | 3. TWITTER_CONSUMER_KEY
392 | 4. TWITTER_CONSUMER_SECRET
393 | 5. SECRET_KEY_BASE
394 | 6. DATABASE_USER
395 | 7. DATABASE_PASS
396 | 8. DATABASE_NAME
397 | 9. DATABASE_HOST
398 |
399 | 一切准备完后将 tweet_bot.tar.gz 文件上传到服务器并解压,之后执行:
400 |
401 | ```sh
402 | $ PORT=4200 bin/tweet_bot start
403 | ```
404 |
405 | 成功了,我们现在已经可以通过 ip:4200 来访问 Phoenix 的默认页面。
406 |
407 | == 配置 Caddy
408 |
409 | 新建一个 `Caddyfile`,文件内容如下:
410 |
411 | ```Caddyfile
412 | https://tweetbot.zfanw.com {
413 | proxy / localhost:4200
414 | ipfilter /api/twitter {
415 | rule allow
416 | ip 149.154.167.197/32 149.154.167.198/31 149.154.167.200/29 149.154.167.208/28 149.154.167.224/29 149.154.167.232/31
417 | }
418 | }
419 | ```
420 |
421 | 然后启动 caddy:
422 |
423 | ```
424 | $ caddy -conf ./Caddyfile
425 | ```
426 | 但我们会看到如下警示:
427 |
428 | > WARNING: File descriptor limit 1024 is too low for production servers. At least 8192 is recommended. Fix with "ulimit -n 8192"
429 |
430 | 解决办法很简单,我们可以在运行 `caddy` 前运行 `ulimit -n 8192`,但这只是临时性的。要让它永久生效,我们需要调整 `/etc/security/limits.conf`,在末尾新增两行:
431 |
432 | ```conf
433 | * soft nofile 20000
434 | * hard nofile 20000
435 | ```
436 | 之后重新连接服务器,并执行 `caddy -conf ./Caddyfile`。
437 |
438 | == 设定 webhook
439 |
440 | 最后一步是设定 telegram 的 webhook。
441 |
442 | == 验证
443 |
444 | 部署完成后,验证发推机器人发现一个问题:生产环境的 OAuth 回调地址同样是 `localhost:4000/auth_callback`,而我们需要的是 `https://tweetbot.zfanw.com/auth_callback`。
445 |
446 | 这个问题非常好解决,调整 `prod.exs` 中的 `url` 即可:
447 |
448 | ```elixir
449 | http: [port: {:system, "PORT"}],
450 | - url: [host: "localhost", port: {:system, "PORT"}],
451 | + url: [scheme: "https", host: "tweetbot.zfanw.com", port: 443],
452 | cache_static_manifest: "priv/static/cache_manifest.json",
453 | ```
454 |
455 | 这样,我们就完成了发推机器人的部署。
456 |
457 |
--------------------------------------------------------------------------------
/notes/high-availability.asc:
--------------------------------------------------------------------------------
1 | = 高可用
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | Erlang 的一大亮点是高可用,但我目前部署的应用则非常脆弱,因为存在严重的 https://en.wikipedia.org/wiki/Single_point_of_failure[单点故障]。
8 |
9 | 先解决单点本身可能发生的故障:
10 |
11 | 1. 服务器重启时,CaddyServer 未能随之重启
12 | 2. 服务器重启时,Phoenix 应用未能随之重启
13 | 3. 每次重启应用,环境变量都要重新 `export`
14 |
15 | 第一个问题,Caddy 提供了几种 https://github.com/mholt/caddy/wiki/Caddy-as-a-service-examples[解决办法],最简单的,是通过 https://github.com/hacdias/caddy-service[hook.service 插件]。
16 |
17 | 来重新安装下 caddy:
18 |
19 | ```sh
20 | $ curl https://getcaddy.com | bash -s personal hook.service,http.ipfilter,http.ratelimit
21 | ```
22 | 然后安装、启动 caddy service:
23 |
24 | ```sh
25 | $ sudo caddy -service install -agree -email your-email@address.com -conf /home/ubuntu/Caddyfile
26 | $ sudo caddy -service start
27 | ```
28 | 之后可以通过 `systemctl status Caddy` 查看 caddy 服务的状态。
29 |
30 |
31 | 第二个问题,我们同样要借助 https://hexdocs.pm/distillery/guides/systemd.html[systemd]。
32 |
33 | 在 `/etc/systemd/system/` 下新建一个 `TweetBot.service`:
34 |
35 | ```
36 | [Unit]
37 | Description=TweetBot
38 | After=network.target
39 |
40 | [Service]
41 | Type=forking
42 | User=ubuntu
43 | Group=ubuntu
44 | WorkingDirectory=/home/ubuntu/tweet_bot
45 | EnvironmentFile=/etc/default/tweet_bot.env
46 | ExecStart=/home/ubuntu/tweet_bot/bin/tweet_bot start
47 | ExecStop=/home/ubuntu/tweet_bot/bin/tweet_bot stop
48 | PIDFile=/home/ubuntu/tweet_bot/tweet_bot.pid
49 | Restart=on-failure
50 | RestartSec=5
51 | Environment=LANG=en_US.UTF-8
52 | Environment=PIDFILE=/home/ubuntu/tweet_bot/tweet_bot.pid
53 | SyslogIdentifier=tweet_bot
54 | RemainAfterExit=no
55 |
56 | [Install]
57 | WantedBy=multi-user.target
58 | ```
59 | 注意,在 service 里,我们的环境变量是从 `/etc/default/tweet_bot.env` 文件读取的,它的格式为:
60 |
61 | ```
62 | PORT=4200
63 | ...
64 | ```
65 |
66 | 接着启动 TweetBot:
67 |
68 | ```sh
69 | $ sudo systemctl daemon-reload
70 | $ sudo systemctl enable TweetBot
71 | $ sudo systemctl start TweetBot
72 | ```
73 | 启动完成后,可以通过 `systemctl status TweetBot` 查看状态。这样,我们就一举解决了开头罗列的 2、3 问题。
74 |
75 | == Load balancer
76 |
77 | 上面我解决了单点本身可能故障的问题,然而该节点出问题的话,整个服务就不再可用。理想的情况,应该配置多个服务节点,组成 load balancer,然而,load balancer 太贵,所以暂时就不再折腾。
78 |
79 | 至此,笔记完成。
80 |
--------------------------------------------------------------------------------
/notes/migrate-distillery-to-mix-release.asc:
--------------------------------------------------------------------------------
1 | = distillery 迁移至 mix release
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 今年六月发布的 Elixir 1.9 已经自带 https://elixir-lang.org/blog/2019/06/24/elixir-v1-9-0-released/[mix release] 功能,所以这一篇里我将尝试把项目从 distillery 迁移至 mix release。
8 |
9 | == 移除 distillery
10 |
11 | 首先,移除 `mix.exs` 中的 distillery:
12 |
13 | ```git
14 | - {:distillery, "~> 2.0"},
15 | ```
16 |
17 | 接着执行 `mix deps.clean distillery --unlock` 将 distillery 从 `mix.lock` 中移除。
18 |
19 | 之后删掉 distillery 配置文件目录 `rel`。
20 |
21 | == config/releases.exs
22 |
23 | 我们知道,Phoenix 的配置分两种:
24 |
25 | 1. 构建时配置 - 在构建发行包时读取
26 | 2. 运行时配置 - 发行包部署至生产环境启动时读取
27 |
28 | distillery 通过 https://hexdocs.pm/distillery/config/runtime.html#config-providers[Config Providers] 来解决运行时配置的问题,Elixir 1.9 则是新增了 `config/releases.exs` 来专门存放运行时配置。
29 |
30 | 我们在 `config` 目录下新建一个 `releases.exs` 文件,并将 `prod.exs` 中运行时配置迁移过来:
31 |
32 | .config/releases.exs
33 | [source,elixir]
34 | ----
35 | import Config
36 |
37 | # Configures token for telegram bot
38 | config :telegram_bot,
39 | token: System.fetch_env!("TELEGRAM_TOKEN")
40 |
41 | # Configures extwitter oauth
42 | config :extwitter, :oauth,
43 | consumer_key: System.fetch_env!("TWITTER_CONSUMER_KEY"),
44 | consumer_secret: System.fetch_env!("TWITTER_CONSUMER_SECRET")
45 |
46 | config :tweet_bot, TweetBotWeb.Endpoint, secret_key_base: System.fetch_env!("SECRET_KEY_BASE")
47 |
48 | # Configure your database
49 | config :tweet_bot, TweetBot.Repo,
50 | username: System.fetch_env!("DATABASE_USER"),
51 | password: System.fetch_env!("DATABASE_PASS"),
52 | database: System.fetch_env!("DATABASE_NAME"),
53 | hostname: System.fetch_env!("DATABASE_HOST")
54 | ----
55 |
56 | 注意,我们这里用的是 `import Config`,不是 `use Mix.Config`,因为发行包里不会有 `Mix`,所以 elixir 1.9 里新增了 `Config` 用于替换 `Mix.Config`。另外我们将旧的 `System.get_env` 改为 `System.fetch_env!`,确保应用启动时环境变量已经就绪,否则将抛出错误。
57 |
58 | == 配置 release
59 |
60 | 在 distillery 里,我们通过 `rel/config.exs` 配置发行包:
61 |
62 | .rel/config.exs
63 | [source,elixir]
64 | ----
65 | environment :prod do
66 | set(include_erts: true)
67 | set(include_src: false)
68 | set(cookie: :"p=$dC[$t:@5>z^yex}K}(M[U4p{V&~X~Is(bR{4sSDr5|g@K>;]O{(zHWQU<4El0")
69 | end
70 | ...
71 | release :tweet_bot do
72 | set(version: current_version(:tweet_bot))
73 |
74 | set(
75 | applications: [
76 | :runtime_tools
77 | ]
78 | )
79 |
80 | set(
81 | config_providers: [
82 | {Mix.Releases.Config.Providers.Elixir, ["${RELEASE_ROOT_DIR}/etc/config.exs"]}
83 | ]
84 | )
85 |
86 | set(
87 | commands: [
88 | migrate: "rel/commands/migrate.sh",
89 | seed: "rel/commands/seed.sh"
90 | ]
91 | )
92 |
93 | set(
94 | overlays: [
95 | {:copy, "config/prod.exs", "etc/config.exs"}
96 | ]
97 | )
98 |
99 | set(pre_start_hooks: "rel/hooks/pre_start")
100 | end
101 | ----
102 | Elixir 1.9 下则通过 `mix.exs` 文件:
103 |
104 | .mix.exs
105 | [source,elixir]
106 | ----
107 | releases: [
108 | tweet_bot: [
109 | include_executables_for: [:unix]
110 | ],
111 | ----
112 |
113 | 我们且尝试在开发环境中运行 `MIX_ENV=prod mix release` 看看:
114 |
115 | ----
116 | $ MIX_ENV=prod mix release
117 | ...
118 | == Compilation error in file lib/tweet_bot/repo.ex ==
119 | ** (ArgumentError) missing :adapter option on use Ecto.Repo
120 | lib/ecto/repo/supervisor.ex:67: Ecto.Repo.Supervisor.compile_config/2
121 | lib/tweet_bot/repo.ex:2: (module)
122 | (stdlib) erl_eval.erl:680: :erl_eval.do_apply/6
123 | ----
124 |
125 | 报错了,实际上,我们运行 `iex -S mix phx.server` 也能看到类似的错误:
126 |
127 | ----
128 | warning: retrieving the :adapter from config files for TweetBot.Repo is deprecated.
129 | Instead pass the adapter configuration when defining the module:
130 |
131 | defmodule TweetBot.Repo do
132 | use Ecto.Repo,
133 | otp_app: :tweet_bot,
134 | adapter: Ecto.Adapters.Postgres
135 |
136 | lib/ecto/repo/supervisor.ex:100: Ecto.Repo.Supervisor.deprecated_adapter/3
137 | lib/ecto/repo/supervisor.ex:64: Ecto.Repo.Supervisor.compile_config/2
138 | lib/tweet_bot/repo.ex:2: (module)
139 | ----
140 | 我们需要按提示将 `adapter` 代码添加到 `lib/tweet_bot/repo.ex` 中,并删掉 `config/releases.exs` 中相应的 `adapter` 部分。
141 |
142 | 再次尝试在开发环境中运行 `MIX_ENV=prod mix release`:
143 |
144 | ----
145 | MIX_ENV=prod mix release
146 | Compiling 21 files (.ex)
147 | Generated tweet_bot app
148 | Release tweet_bot-0.0.5 already exists. Overwrite? [Yn]
149 | * assembling tweet_bot-0.0.5 on MIX_ENV=prod
150 | * using config/releases.exs to configure the release at runtime
151 |
152 | Release created at _build/prod/rel/tweet_bot!
153 |
154 | # To start your system
155 | _build/prod/rel/tweet_bot/bin/tweet_bot start
156 |
157 | Once the release is running:
158 |
159 | # To connect to it remotely
160 | _build/prod/rel/tweet_bot/bin/tweet_bot remote
161 |
162 | # To stop it gracefully (you may also send SIGINT/SIGTERM)
163 | _build/prod/rel/tweet_bot/bin/tweet_bot stop
164 |
165 | To list all commands:
166 |
167 | _build/prod/rel/tweet_bot/bin/tweet_bot
168 | ----
169 | 一切顺利。
170 |
171 | == build.sh
172 |
173 | 在使用 distillery 时,我曾写过一个 `build.sh` 用于在 docker 中执行构建过程:
174 |
175 | .bin/build.sh
176 | [source,sh]
177 | ----
178 | #!/usr/bin/env bash
179 |
180 | set -e
181 |
182 | cd /opt/build/app
183 |
184 | APP_NAME="$(grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g')"
185 | APP_VSN="$(grep 'version:' mix.exs | cut -d '"' -f2)"
186 |
187 | mkdir -p /opt/build/rel/artifacts
188 |
189 | export MIX_ENV=prod
190 |
191 | # Fetch deps and compile
192 | mix deps.get --only prod
193 | # Run an explicit clean to remove any build artifacts from the host
194 | mix do clean, compile --force
195 | cd ./assets
196 | npm install
197 | npm run deploy
198 | cd ..
199 | mix phx.digest
200 | # Build the release
201 | mix release
202 | # Copy tarball to output
203 | cp "_build/prod/rel/$APP_NAME/releases/$APP_VSN/$APP_NAME.tar.gz" rel/artifacts/"$APP_NAME-$APP_VSN.tar.gz"
204 |
205 | exit 0
206 | ----
207 | 我们需要做些调整:
208 |
209 | [source,sh]
210 | ----
211 | #!/usr/bin/env bash
212 |
213 | set -e
214 |
215 | cd /opt/build/app
216 |
217 | APP_NAME="$(grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g')"
218 | APP_VSN="$(grep 'version:' mix.exs | cut -d '"' -f2)"
219 |
220 | export MIX_ENV=prod
221 |
222 | # Fetch deps and compile
223 | mix deps.get --only prod
224 | # Run an explicit clean to remove any build artifacts from the host
225 | mix do clean, compile --force
226 | cd ./assets
227 | npm install
228 | npm run deploy
229 | cd ..
230 | mix phx.digest
231 | # Build the release
232 | mix release
233 |
234 | # Copy tarball to output
235 | # cp "_build/prod/rel/$APP_NAME/releases/$APP_VSN/$APP_NAME.tar.gz" rel/artifacts/"$APP_NAME-$APP_VSN.tar.gz"
236 |
237 | exit 0
238 | ----
239 | 我们去掉了 `--env=prod`,并注释掉了 `tarball` 相关的代码,因为 `mix release` 不会像 distillery 一样生成 .tar.gz 文件,需要我们自行压缩。
240 |
241 | == 构建
242 |
243 | 我们仍要用 docker 来构建,只不过这回 https://github.com/chenxsan/docker-elixir-1.9-ubuntu-16.04[dockerfile] 也需要更新到 elixir 1.9 了。
244 |
245 | 接下来在命令行下执行:
246 |
247 | [source,sh]
248 | ----
249 | $ docker run -v $(pwd):/opt/build/app --rm -it chenxsan/elixir-1.9-ubuntu-16.04:latest /opt/bui
250 | ld/app/bin/build.sh
251 | ----
252 | 就可以在项目根目录下的 `_build/prod/rel/tweet_bot` 得到我们的发行包 - 可在 Ubuntu 16.04 上运行的发行包。将目录打包成 tweet_bot.tar.gz 上传至生产环境解压即可部署。
253 |
254 | == 部署
255 |
256 | 在启动程序前,我们需要在生产环境上配置好所有环境变量。最简单的办法是 `export`,比如:
257 |
258 | [source,sh]
259 | ----
260 | $ export TELEGRAM_TOKEN=xxxxx
261 | ----
262 |
263 | 当然,这个方案并不可持续,因为我们每次部署都得连上服务器重新 `export` 一遍,没几人吃得消这样。
264 |
265 | mix release 提供了另一个办法, https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-vm-args-and-env-sh-env-bat[rel/env.sh.eex]。
266 |
267 | 不过我们不需要手动生成该文件,可以执行 `mix release.init` 来自动生成,之后将所有的 `export` 加入 `rel/env.sh.eex` 文件中:
268 |
269 | .rel/env.sh.eex
270 | [source,elixir]
271 | ----
272 | export PORT=
273 | export TELEGRAM_TOKEN
274 | ...
275 | ----
276 |
277 | 构建时该文件会被拷入发行包,并在程序启动前执行。
278 |
279 | == migrate
280 |
281 | 那么,我们在 mix release 下要如何 migrate 我们的数据库呢?与 distillery 类似,我们要定义一个模块,在其中执行 `migrate`。我们可以复用此前的 `lib/release_tasks.ex` 文件,改造 https://github.com/phoenixframework/phoenix/blob/master/guides/deployment/releases.md#ecto-migrations-and-custom-commands[如下]:
282 |
283 | .lib/release_tasks.ex
284 | [source,elixir]
285 | ----
286 | defmodule TweetBot.Release do
287 | @app :tweet_bot
288 |
289 | def migrate do
290 | load_app()
291 |
292 | for repo <- repos() do
293 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
294 | end
295 | end
296 |
297 | def rollback(repo, version) do
298 | load_app()
299 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
300 | end
301 |
302 | defp repos do
303 | Application.fetch_env!(@app, :ecto_repos)
304 | end
305 |
306 | defp load_app do
307 | Application.load(@app)
308 | end
309 | end
310 | ----
311 |
312 | 不过 `Ecto.Migrator.with_repo` 是 https://github.com/elixir-ecto/ecto_sql/blob/ff6f2800ee945d08ce0fd67a13247b14b4050d86/CHANGELOG.md#v312-2019-05-11[ecto_sql] 3.1.2 新增的,而我们目前 `mix.lock` 中相应版本还是 3.0.3,所以需要升级一下:
313 |
314 | [source,sh]
315 | ----
316 | $ mix deps.update ecto_sql
317 | ----
318 |
319 | 这样我们就可以通过 `bin/tweet_bot eval "TweetBot.Release.migrate()"` 来执行 migrate 了。
320 |
321 | == pre_start
322 |
323 | 不,`mix release` 没有提供 https://elixirforum.com/t/equivalent-to-distillerys-boot-hooks-in-mix-release-elixir-1-9/23431[pre_start]。具体原因及可能的解决办法见 https://elixirforum.com/t/equivalent-to-distillerys-boot-hooks-in-mix-release-elixir-1-9/23431[链接]。
324 |
325 | == 启动
326 |
327 | [source,sh]
328 | ----
329 | $ bin/tweet_bot daemon
330 | ----
--------------------------------------------------------------------------------
/notes/more-tests.asc:
--------------------------------------------------------------------------------
1 | = 测试
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 在前面几节,我给 `User` 结构写过测试,当时号称这是测试驱动。但随后新增的代码,不管是路由还是控制器或其它,均没有测试先行。这一节我们来亡羊补牢。
8 |
9 | == 路由
10 |
11 | 我们已经知道,post `/start` 消息到 `/api/twitter` 接口有两种可能结果:
12 |
13 | 1. 用户未授权,返回 OAuth 授权链接
14 | 2. 用户已授权,提示用户直接发送消息
15 |
16 | 但未授权的情况下,ExTwitter 需要与 twitter api 通信,我们的测试将依赖网络状况,这是应避免的。
17 |
18 | 我们来优化下代码,让 `twitter_controller.ex` 代码便于测试。
19 |
20 | 首先在 `lib/tweet_bot_web/controllers` 目录下新增一个 `twitter_api.ex` 文件:
21 |
22 | .lib/tweet_bot_web/controllers/twitter_api.ex
23 | ```elixir
24 | defmodule TwitterAPI do
25 |
26 | def request_token(redirect_url \\ nil) do
27 | ExTwitter.request_token(redirect_url)
28 | end
29 |
30 | def authenticate_url(token) do
31 | ExTwitter.authenticate_url(token)
32 | end
33 | end
34 | ```
35 | 它很简单,就是 ExTwitter 的 API 的再封装,之所以要再度封装,主要是方便我们后面的测试。
36 |
37 | 接着,我们在 `config.exs` 里给应用定义一个环境变量 `twitter_api`:
38 |
39 | ```elixir
40 | config :tweet_bot,
41 | twitter_api: TwitterAPI
42 | ```
43 | 这样我们就可以在 `twitter_controller.ex` 里读取并调用它:
44 |
45 | ```elixir
46 | @twitter_api Application.get_env(:tweet_bot, :twitter_api)
47 | ```
48 | 然后将 `twitter_controller.ex` 中的 `get_twitter_oauth` 函数中的 ExTwitter 替换为 `@twitter_api`:
49 |
50 | ```elixir
51 | defp get_twitter_oauth(conn, from_id) do
52 | token =
53 | @twitter_api.request_token(
54 | URI.encode_www_form(
55 | TweetBotWeb.Router.Helpers.auth_url(conn, :callback) <> "?from_id=#{from_id}"
56 | )
57 | )
58 |
59 | {:ok, authenticate_url} = @twitter_api.authenticate_url(token.oauth_token)
60 | ```
61 | 这一切改造都是为了方便测试。
62 |
63 | 那么,要如何测试?我们来试试 https://hexdocs.pm/mox/Mox.html[`Mox`]。
64 |
65 | Mox 有几条原则,其中一条说:
66 |
67 | > mocks 应该基于行为(behaviours)
68 |
69 | Elixir 下,行为定义的是接口,而我们要测试的代码与它们的 mock 均是行为的一种实现。
70 |
71 | 复杂?有点。
72 |
73 | 我们先来定义个 `Twitter` 行为,在 `lib/tweet_bot_web/controllers` 目录下新建一个 `twitter.ex` 文件:
74 |
75 | .lib/tweet_bot_web/controllers/twitter.ex
76 | ```elixir
77 | defmodule Twitter do
78 | @callback request_token(String.t()) :: map()
79 | @callback authenticate_url(String.t()) :: {:ok, String.t()} | {:error, String.t()}
80 | end
81 | ```
82 | 然后调整 `twitter_api.ex` 文件,新增一行:
83 |
84 | ```elixir
85 | @behaviour Twitter
86 | ```
87 | 这样 `TwitterAPI` 就是 `Twitter` 行为的一个具体实现了。
88 |
89 | 我们的 Mock 将同样是 `Twitter` 的一个实现。
90 |
91 | 在 `test/support` 目录下新建 `mocks.ex` 文件:
92 |
93 | ```elixir
94 | Mox.defmock(TwitterMock, for: Twitter)
95 | ```
96 | 接着,在 `test/tweet_bot_web/controllers` 目录下新增 `twitter_controller_test.exs` 文件:
97 |
98 | .test/tweet_bot_web/controllers/twitter_controller_test.exs
99 | ```elixir
100 | defmodule TweetBotWeb.TwitterControllerTest do
101 | use TweetBotWeb.ConnCase
102 | import Mox
103 |
104 | setup :verify_on_exit!
105 |
106 | @valid_message %{
107 | "message" => %{
108 | "from" => %{
109 | "id" => 123
110 | },
111 | "text" => "/start"
112 | }
113 | }
114 |
115 | test "POST /api/twitter with /start first time", %{conn: conn} do
116 | TwitterMock
117 | |> expect(:request_token, fn _ -> %{oauth_token: ""} end)
118 | |> expect(:authenticate_url, fn _ -> {:ok, "https://blog.zfanw.com"} end)
119 |
120 | conn = post(conn, "/api/twitter", @valid_message)
121 |
122 | assert json_response(conn, 200) == %{
123 | "chat_id" => 123,
124 | "method" => "sendMessage",
125 | "parse_mode" => "HTML",
126 | "text" => "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter"
127 | }
128 | end
129 | end
130 | ```
131 | 大部分代码是参照 Mox 文档写的,`TwitterMock` 的具体实现是通过 `expect` 实现的。
132 |
133 | 那么,我要如何保证 `twitter_controller.ex` 代码在遇到 `@twitter_api` 时调用 `TwitterMock` 而不是 `TwitterAPI`?很简单,我们在 `test.exs` 里覆盖 `config.exs` 中定义的 `twitter_api` 环境变量:
134 |
135 | ```elixir
136 | config :tweet_bot,
137 | twitter_api: TwitterMock
138 | ```
139 | 就这样。
140 |
141 | 运行 `mix test`,测试悉数通过。
142 |
143 | 同理,我们可以测试其它 POST /api/twitter 的情况。
144 |
--------------------------------------------------------------------------------
/notes/ngrok-web-interface.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxsan/telegram-bot-for-twitter/892107c7609123028ac2375342cd7b2329931635/notes/ngrok-web-interface.jpeg
--------------------------------------------------------------------------------
/notes/optimize-and-fix.asc:
--------------------------------------------------------------------------------
1 | = 优化及修补
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 在部署之前,我想对代码再做一些优化及修补。
8 |
9 | == 减少 telegram API 调用
10 |
11 | 我们在前面提到过,telegram api 是有限制的,比如超过 30 次每秒,再调用就会报错。因此我很少调用 `sendMessage` 给用户发送消息。
12 |
13 | 但从 https://core.telegram.org/bots/faq#how-can-i-make-requests-in-response-to-updates[telegram 某处文档]我们得知,webhook 在收到 telegram POST 的消息后,是可以直接响应指令的 - 等效于我们调用 `sendMessage`,唯一的问题是,响应之后,我们无法知道该响应中的指令是否成功。
14 |
15 | 但我觉得值得一试。
16 |
17 | 我们先做一小部分优化,验证下可行性:
18 |
19 | ```elixir
20 | - e in ExTwitter.Error -> sendMessage(conn.assigns.current_user, "#{e.message}")
21 | + e in ExTwitter.Error ->
22 | + json(conn, %{
23 | + "method" => "sendMessage",
24 | + "text" => e.message,
25 | + "chat_id" => conn.assigns.current_user
26 | + })
27 | end
28 | -
29 | - json(conn, %{})
30 | end
31 | ```
32 |
33 | 测试后发现可行。
34 |
35 | 我们可以将其它部分也做同样修改。
36 |
37 | 这样,我们就基本不再调用 telegram 的 `sendMessage` api 了。
38 |
39 | == 捕捉 HTTPoison.get! 错误
40 |
41 | 我们目前的 `twitter_controller.ex` 代码中并没有捕捉 `HTTPoison.get!` 可能发生的错误,改造一下我们的代码,将 `HTTPoison.get!` 移入 `try` 语句中:
42 |
43 | ```elixir
44 | try do
45 | %HTTPoison.Response{body: body} =
46 | HTTPoison.get!(
47 | "https://api.telegram.org/file/bot#{Application.get_env(:telegram_bot, :token)}/#{
48 | file |> Map.get("file_path")
49 | }",
50 | []
51 | )
52 |
53 | ExTwitter.update_with_media(caption, body)
54 | rescue
55 | e in ExTwitter.Error ->
56 | json(conn, %{
57 | "method" => "sendMessage",
58 | "text" => e.message,
59 | "chat_id" => conn.assigns.current_user
60 | })
61 |
62 | e in HTTPoison.Error ->
63 | json(conn, %{
64 | "method" => "sendMessage",
65 | "text" => e.reason,
66 | "chat_id" => conn.assigns.current_user
67 | })
68 | end
69 | ```
70 |
71 | == action_fallback
72 |
73 | 顾名思义,你可能已经猜出 `action_fallback` 的作用。
74 |
75 | 首先,`action_fallback` 是一个 Plug,这意味着它至少定义了一个 `call` 函数。另外,它是 action 的 fallback,处理的是 action 未返回 `conn` 的情况,换句话说,一个 action 在函数末没有返回 `conn`,`action_fallback` 就会启动。
76 |
77 | 那么,它的应用场景是什么?
78 |
79 | 我们来看 `twitter_controller.ex` 文件,其中多处出现 `{:error, {_, reason}} ->` 的代码,我们不妨通过 `action_fallback` 来集中处理这些。
80 |
81 | 首先在 `controllers` 目录下新增一个 `fallback_controller.ex`:
82 |
83 | ```elixir
84 | defmodule FallbackController do
85 | use Phoenix.Controller
86 |
87 | def call(conn, {:error, {_, reason}}) do
88 | json(conn, %{
89 | "method" => "sendMessage",
90 | "chat_id" => conn.assigns.current_user,
91 | "text" => reason
92 | })
93 | end
94 | end
95 | ```
96 |
97 | 接着在 `twitter_controller.ex` 中引用 `action_fallback(FallbackController)`:
98 |
99 | ```elixir
100 | plug(:find_user)
101 | plug(:configure_extwitter)
102 | + action_fallback(FallbackController)
103 | ```
104 |
105 | 最后清理掉 `twitter_controller.ex` 文件中如下代码:
106 |
107 | ```elixir
108 | {:error, {_, reason}} ->
109 | json(conn, %{
110 | "method" => "sendMessage",
111 | "chat_id" => conn.assigns.current_user,
112 | "text" => reason
113 | })
114 | ```
115 |
116 | 你看,我们在 `TwitterController` 中未处理的 `{:error, {_, reason}}` 都由 `FallbackController` 接手了 - 这样 `index` 可以更专注于处理正确的情况。
117 |
118 | 同理,我们可以将 `rescue` 中的错误处理统一交 `FallbackController` 处理。
119 |
120 | == `/start` 命令
121 |
122 | 我们的 `/start` 命令有一个 bug:
123 |
124 | 1. 用户初次使用,发送 `/start`,授权成功后,数据库存储 token
125 | 2. 用户到 twitter 设置中取消授权
126 | 3. 用户再次发送 `/start` - 数据库存储的 token 其实已经失效,此时我们应该返回授权链接,而不是提示用户直接发送信息
127 |
128 | 一个办法,是在接收到 `/start` 命令后检查 token 有效性来决定具体返回什么给用户:
129 |
130 | ```elixir
131 | def index(conn, %{"message" => %{"text" => "/start"}}) do
132 | try do
133 | ExTwitter.verify_credentials()
134 |
135 | json(conn, %{
136 | "method" => "sendMessage",
137 | "text" => "已授权,请直接发送消息",
138 | "chat_id" => conn.assigns.current_user
139 | })
140 | rescue
141 | _ ->
142 | %{"message" => %{"from" => %{"id" => from_id}}} = conn.params
143 |
144 | token =
145 | ExTwitter.request_token(
146 | URI.encode_www_form(
147 | TweetBotWeb.Router.Helpers.auth_url(conn, :callback) <> "?from_id=#{from_id}"
148 | )
149 | )
150 |
151 | {:ok, authenticate_url} = ExTwitter.authenticate_url(token.oauth_token)
152 |
153 | conn
154 | |> json(%{
155 | "method" => "sendMessage",
156 | "chat_id" => from_id,
157 | "text" =>
158 | "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter",
159 | "parse_mode" => "HTML"
160 | })
161 | |> halt()
162 | end
163 | end
164 | ```
165 |
166 | 不过,这样的 `index` 与 `find_user` 函数有大量重复,我们来优化一下 `twitter_controller.ex`。
167 |
168 | 新增一个 `get_twitter_oauth` 方法:
169 |
170 | ```elixir
171 | defp get_twitter_oauth(conn, from_id) do
172 | token =
173 | ExTwitter.request_token(
174 | URI.encode_www_form(
175 | TweetBotWeb.Router.Helpers.auth_url(conn, :callback) <> "?from_id=#{from_id}"
176 | )
177 | )
178 |
179 | {:ok, authenticate_url} = ExTwitter.authenticate_url(token.oauth_token)
180 |
181 | conn
182 | |> json(%{
183 | "method" => "sendMessage",
184 | "chat_id" => from_id,
185 | "text" => "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter",
186 | "parse_mode" => "HTML"
187 | })
188 | end
189 | ```
190 | 接着调整 `index` 与 `find_user`:
191 |
192 | ```elixir
193 | - token =
194 | - ExTwitter.request_token(
195 | - URI.encode_www_form(
196 | - TweetBotWeb.Router.Helpers.auth_url(conn, :callback) <> "?from_id=#{from_id}"
197 | - )
198 | - )
199 | -
200 | - {:ok, authenticate_url} = ExTwitter.authenticate_url(token.oauth_token)
201 | -
202 | - conn
203 | - |> json(%{
204 | - "method" => "sendMessage",
205 | - "chat_id" => from_id,
206 | - "text" =>
207 | - "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter",
208 | - "parse_mode" => "HTML"
209 | - })
210 | - |> halt()
211 | + get_twitter_oauth(conn, from_id) |> halt()
212 | ```
213 |
214 | == from_id 被占用
215 |
216 | 我们前面描述了用户在 twitter 设置中取消授权的情形。是的,我们的代码现在能够检查数据库中 token 的有效性,然而用户再次授权时,代码里就会产生错误,这个错误来自:
217 |
218 | ```elixir
219 | |> unique_constraint(:from_id, message: "已被占用")
220 | ```
221 | 因为数据库里有旧的无效数据。怎么避免呢?也很简单,考虑两种情景:
222 |
223 | 1. 用户已经存在的时候,数据插入应该调整为**更新**
224 | 2. 用户不存在的时候,数据直接插入
225 |
226 | 改造 `auth_controller` 代码如下:
227 |
228 | ```elixir
229 | def callback(conn, %{
230 | "from_id" => from_id,
231 | "oauth_token" => oauth_token,
232 | "oauth_verifier" => oauth_verifier
233 | }) do
234 | # 获取 access token
235 | case ExTwitter.access_token(oauth_verifier, oauth_token) do
236 | {:ok, token} ->
237 | case Accounts.get_user_by_from_id(from_id) do
238 | user when not is_nil(user) ->
239 | case Accounts.update_user(user, %{
240 | access_token: token.oauth_token,
241 | access_token_secret: token.oauth_token_secret
242 | }) do
243 | {:ok, _user} -> text(conn, "授权成功,请关闭此页面")
244 | {:error, _changeset} -> text(conn, "授权失败。")
245 | end
246 |
247 | nil ->
248 | case Accounts.create_user(%{
249 | from_id: from_id,
250 | access_token: token.oauth_token,
251 | access_token_secret: token.oauth_token_secret
252 | }) do
253 | {:ok, _} -> text(conn, "授权成功,请关闭此页面")
254 | {:error, _changeset} -> text(conn, "授权失败。")
255 | end
256 | end
257 |
258 | {:error, reason} ->
259 | text(conn, "授权失败:#{reason}")
260 | end
261 | end
262 | ```
263 |
--------------------------------------------------------------------------------
/notes/plan.asc:
--------------------------------------------------------------------------------
1 | = 规划
2 | 陈三
3 | :!webfonts:
4 | :imagesdir: ./
5 | :source-highlighter: pygments
6 |
7 | 我初步设想的流程是这样:
8 |
9 | . 用户添加机器人,此时服务器收到客户端发来的 `/start` 消息,返回 twitter OAuth 授权链接
10 | . 用户点击 OAuth 授权链接登录他们的 twitter 账户并完成授权
11 | . 授权成功后,数据库中保存用户的 telegram id 及 twitter 的 access token
12 | . 用户发送消息给机器人
13 | . 服务器收到用户消息,根据用户 id 获取数据库中对应的 access token 然后提交消息给 twitter api
14 | . 服务器收到 twitter 响应,视情况决定是否给用户一个反馈
15 |
16 | 然而在开发真正开始前,我们还需要解决一个问题。
17 |
18 | ## 本地开发的问题
19 |
20 | 我们知道,webhook 必须是线上可访问的 - 不管它是微信公众号的还是 telegram 的。而在开发阶段,Phoenix 服务运行在本地,外网无法访问到。
21 |
22 | 我能想到的解决办法是,设定一个公开的 webhook,但在它收到 telegram 推送的消息时,转发到本地 Phoenix 开发服务器。
23 |
24 | 俩种方案:
25 |
26 | 1. Reverse ssh tunnel - 因为墙的干扰,这个稳定性很差,连接经常断掉,需要重连,严重影响开发体验
27 | 2. https://github.com/inconshreveable/ngrok[ngrok] - 这个虽然被墙,但墙不是问题,而且据我的使用体验,ngrok 十分稳定,另外它还有可视化界面,便于我们查看 http 请求与响应。
28 |
29 | 权衡后,我选择 ngrok。
30 |
31 | 安装 ngrok 后执行命令:
32 |
33 | ```sh
34 | $ /Applications/ngrok http 4000
35 | ngrok by @inconshreveable (Ctrl+C to quit)
36 |
37 | Session Status online
38 | Account Sam Chen (Plan: Free)
39 | Version 2.2.8
40 | Region United States (us)
41 | Web Interface http://127.0.0.1:4040
42 | Forwarding http://fd80be0a.ngrok.io -> localhost:4000
43 | Forwarding https://fd80be0a.ngrok.io -> localhost:4000
44 |
45 | Connections ttl opn rt1 rt5 p50 p90
46 | 0 0 0.00 0.00 0.00 0.00
47 | ```
48 |
49 | ngrok 会在本地 4000 端口即我们的 Phoenix 开发服务器与 ngrok 的随机公共网址间形成映射。
50 |
51 | 接着访问 telegram 设定 webhook 的网址,将 webhook 设置为 ngrok 随机生成的地址 `https://fd80be0a.ngrok.io`(注意 telegram 要求 webhook 必须是 https,不能是 http):
52 |
53 | > `https://api.telegram.org/botTOKEN_D/setwebhook?url=https://fd80be0a.ngrok.io/api/twitter`
54 |
55 | 当然,我们的 `/api/twitter` 路由目前还没有创建。
56 |
57 | 之后本地开发服务器就能收到线上 webhook 转发来的 telegram 消息,如下图:
58 |
59 | image::ngrok-web-interface.jpeg[ngrok web interface]
--------------------------------------------------------------------------------
/notes/ready-go.asc:
--------------------------------------------------------------------------------
1 | = 准备工作
2 | 陈三
3 | :icons: font
4 | :!webfonts:
5 | :toc:
6 | :source-highlighter: pygments
7 |
8 | == 准备开发环境
9 |
10 | 我目前使用的操作系统是 macOS,macOS 上可以借助 https://brew.sh[homebrew] 安装 Elixir:
11 |
12 | ```bash
13 | $ brew install elixir
14 | ```
15 |
16 | TIP: 通常我们要先安装 Erlang,这里没有提到 Erlang 的安装是因为 `brew install elixir` 命令帮我们一并安装好了。
17 |
18 | 安装完 Elixir 及 Erlang 后,我们可以通过 `mix hex.info` 命令查看它们的版本号:
19 |
20 | ```bash
21 | $ mix hex.info
22 | Hex: 0.18.1
23 | Elixir: 1.7.3
24 | OTP: 21.0.9
25 |
26 | Built with: Elixir 1.6.6 and OTP 19.3
27 | ```
28 |
29 | 随后运行 mix 命令安装 Phoenix:
30 |
31 | ```bash
32 | $ mix archive.install hex phx_new 1.4.0
33 | ```
34 |
35 | `mix phx.new --version` 可以查看当前安装的 Phoenix 版本号:
36 |
37 | ```bash
38 | $ mix phx.new --version
39 | Phoenix v1.4.0
40 | ```
41 | 至于 Node.js - 我作为一个专职前端开发,当然是早已安装:
42 |
43 | ```bash
44 | $ node -v
45 | v8.11.1
46 | ```
47 |
48 | == 初始化项目
49 |
50 | 开发环境准备就绪后,执行 `mix phx.new` 命令初始化我们的发推机器人项目:
51 |
52 | ```bash
53 | $ mix phx.new tweet_bot
54 | ```
55 | Phoenix 默认使用 PostgreSQL 数据库,如果你想使用 MySQL,请在命令行下额外指定 `--database mysql`。
56 |
57 | 项目初始化成功后,我们会在命令行中看到如下提示:
58 |
59 | ```bash
60 | We are all set! Go into your application by running:
61 |
62 | $ cd tweet_bot
63 |
64 | Then configure your database in config/dev.exs and run:
65 |
66 | $ mix ecto.create
67 |
68 | Start your Phoenix app with:
69 |
70 | $ mix phx.server
71 |
72 | You can also run your app inside IEx (Interactive Elixir) as:
73 |
74 | $ iex -S mix phx.server
75 | ```
76 | 按提示操作,就能在 http://0.0.0.0:4000 上启动 Phoenix 开发服务器。
77 |
78 | == 创建 telegram bot
79 |
80 | 我需要创建两个 telegram 机器人,一个用于开发环境,一个用于生产环境:
81 |
82 | . https://t.me/tweet_for_me_test_bot[https://t.me/tweet_for_me_test_bot] - 开发用途
83 | . https://t.me/tweet_for_me_bot[https://t.me/tweet_for_me_bot] - 生产环境使用
84 |
85 | 具体的创建 telegram 机器人过程请查阅 https://core.telegram.org/bots#3-how-do-i-create-a-bot[telegram 文档],这里略过不表。
86 |
87 | 创建好 telegram 机器人,我们将获得 `token`,假定它们分别为:
88 |
89 | . `TOKEN_D` - 开发环境的 telegram 机器人 token
90 | . `TOKEN_P` - 生产环境的 telegram 机器人 token
91 |
92 | 后面我们将用它们来设置 https://core.telegram.org/bots/api#setwebhook[webhook]。
--------------------------------------------------------------------------------
/notes/reply.asc:
--------------------------------------------------------------------------------
1 | = 回复
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 服务器想要回复消息给用户的话,需要一个 telegram bot api 的 elixir 库,因为所需功能非常少,所以这里就用我以前写的一个简单的 https://github.com/chenxsan/TelegramBot[TelegramBot] 库。
8 |
9 | 在 `mix.exs` 文件 `deps` 中新增 `{:telegram_bot, "~> 1.0.1}`:
10 |
11 | ```
12 | + {:telegram_bot, "~> 1.0.1"}
13 | ]
14 | ```
15 |
16 | 接着在命令行下运行 `mix deps.get` 安装 `telegram_bot` 依赖。
17 |
18 | == sendMessage
19 |
20 | `TwitterController` 的 `index` 动作在接收到 telegram 消息后,需要调用 `telegram_bot` 提供的 `sendMessage` 方法,回复消息给用户。
21 |
22 | 但我们需要先配置 telegram 的 token,打开 `dev.exs` 文件,在文件末尾新增内容如下:
23 |
24 | ./config/dev.exs
25 | ```elixir
26 | # Configures token for telegram bot
27 | config :telegram_bot,
28 | token: System.get_env("TELEGRAM_TOKEN")
29 | ```
30 | 这样,telegram token 就可以从环境变量中读取。
31 |
32 | 命令行下配置 `TELEGRAM_TOKEN` 环境变量:
33 |
34 | ```sh
35 | $ export TELEGRAM_TOKEN=TOKEN_D
36 | ```
37 |
38 | 注意,调整 `dev.exs` 后需要重启 Phoenix 服务器。
39 |
40 | WARNING: 因为 telegram 被墙,所以我还需要给终端设置代理,`telegram_bot` API 会自动读取环境变量中的代理设置。
41 |
42 | === 回复“你好”
43 |
44 | 上述准备工作完成后,就可以调整 `twitter_controller.ex` 中的代码:
45 |
46 | ```elixir
47 | defmodule TweetBotWeb.TwitterController do
48 | use TweetBotWeb, :controller
49 |
50 | + import TelegramBot
51 | +
52 | def index(conn, %{"message" => %{"from" => %{"id" => from_id}, "text" => text}}) do
53 | + sendMessage(from_id, "你好")
54 | json conn, %{}
55 | end
56 | end
57 | ```
58 | 现在给测试用的发推机器人发送任何消息,都会收到“你好”的回复。
--------------------------------------------------------------------------------
/notes/save-user.asc:
--------------------------------------------------------------------------------
1 | = 保存用户数据
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 在上一节,我们已经获取到用户的如下数据:
8 |
9 | 1. `from_id`
10 | 2. `access_token`
11 | 3. `access_token_secret`
12 |
13 | 接下来就是将它们保存到数据库中。
14 |
15 | 不过有一个问题,`from_id` 与 `access_token` 其实分处两个请求中,我们如何在用户授权成功后获取到 `from_id` 值?
16 |
17 | 一个简单办法,是在传递 twitter 回调网址时一并将 `from_id` 传出。
18 |
19 | 打开 `twitter_controller.ex` 文件,修改代码如下:
20 |
21 | .lib/tweet_bot_web/controllers/twitter_controller.ex
22 | ```elixir
23 | - Routes.auth_url(conn, :callback)
24 | + Routes.auth_url(conn, :callback) <> "?from_id=#{from_id}")
25 | ```
26 | 测试发现,可行。
27 |
28 | 接着调整 `auth_controller.ex`,将授权成功后的自动发推去掉 - 我想没人希望出现这种乱发推的情况。
29 |
30 | .lib/tweet_bot_web/controllers/auth_controller.ex
31 | ```elixir
32 | defmodule TweetBotWeb.AuthController do
33 | use TweetBotWeb, :controller
34 | + alias TweetBot.Accounts
35 |
36 | - def callback(conn, %{"oauth_token" => oauth_token, "oauth_verifier" => oauth_verifier}) do
37 | + def callback(conn, %{
38 | + "from_id" => from_id,
39 | + "oauth_token" => oauth_token,
40 | + "oauth_verifier" => oauth_verifier
41 | + }) do
42 | # 获取 access token
43 | {:ok, token} = ExTwitter.access_token(oauth_verifier, oauth_token)
44 |
45 | - ExTwitter.configure(
46 | - :process,
47 | - Enum.concat(
48 | - ExTwitter.Config.get_tuples(),
49 | - access_token: token.oauth_token,
50 | - access_token_secret: token.oauth_token_secret
51 | - )
52 | - )
53 | -
54 | - ExTwitter.update("I just sign up telegram bot tweet_for_me_bot.")
55 | - text(conn, "授权成功,请关闭此页面")
56 | + case Accounts.create_user(%{
57 | + from_id: from_id,
58 | + access_token: token.oauth_token,
59 | + access_token_secret: token.oauth_token_secret
60 | + }) do
61 | + {:ok, _} -> text(conn, "授权成功,请关闭此页面")
62 | + {:error, _changeset} -> text(conn, "授权失败")
63 | + end
64 | end
65 | end
66 | ```
67 | 再跑一遍,在“授权成功”信息出现后,查看本地数据库 - 已成功插入数据。
68 |
69 | 那么,下次用户再发送 `/start` 时,我们就可以检查 `token` 是否已存在,再来决定是要求用户授权,还是提示用户直接发送消息。
--------------------------------------------------------------------------------
/notes/security.asc:
--------------------------------------------------------------------------------
1 | = 关于安全
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 你看,只要你愿意,你就可以伪造一条 telegram 消息,然后 POST 到我的 webhook。
8 |
9 | 这一点,微信公众号要做得更好,因为你需要验证消息来源。
10 |
11 | 不过我们还是有些办法的。
12 |
13 | 我们从 https://core.telegram.org/bots/webhooks[https://core.telegram.org/bots/webhooks] 文档里看到如下一句:
14 |
15 | > Accepts incoming POSTs from 149.154.167.197-233 on port 443,80,88 or 8443.
16 |
17 | 是了,我们可以考虑限定流量的来源。
18 |
19 | telegram 还给了另一条 https://core.telegram.org/bots/faq#how-can-i-make-sure-that-webhook-requests-are-coming-from-telegr[建议]:
20 |
21 | > If you‘d like to make sure that the Webhook request comes from Telegram, we recommend using a secret path in the URL you give us, e.g. www.example.com/your_token. Since nobody else knows your bot’s token, you can be pretty sure it's us.
22 |
23 | 它推荐我们使用一个隐藏的 webhook 路径,比如把 `/api/twitter` 换成 `/api/xljfdlsajflsdfjsaf` 这样。
24 |
25 | == 数据安全
26 |
27 | 用户登录 Twitter 并授权后,我们得到用户的 `oauth_token` 与 `oauth_token_secret`。目前我们是明文保存这俩个数据,这意味着,数据库被人侵入的话,攻击者如果再拿到应用的 Consumer Key 与 Consumer Secret,就可以读写用户的 timeline - 当然,这种情况发生的概率非常低,因为我的数据库与应用程序跑在不同服务器上。另外,一旦发生攻击事件,用户可以在 https://twitter.com/settings/applications[https://twitter.com/settings/applications] 里取消对此应用的授权,及时止损。
28 |
29 | 所以我暂时没有打算加密存储 `oauth_token` 及 `oauth_token_secret` - 直到有资料说服我为止。
--------------------------------------------------------------------------------
/notes/send-tweet.asc:
--------------------------------------------------------------------------------
1 | = 发推
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 我们不妨把 `/start` 命令以外的文本全部认为是用户要发送的推文。
8 |
9 | 改造 `twitter_controller.ex` 如下:
10 |
11 | .lib/tweet_bot_web/controllers/twitter_controller.ex
12 | ```elixir
13 | - def index(conn, _) do
14 | + def index(conn, %{"message" => %{"text" => text}}) do
15 | + # 读取用户 token
16 | + user = Accounts.get_user_by_from_id!(conn.assigns.current_user)
17 | +
18 | + ExTwitter.configure(
19 | + :process,
20 | + Enum.concat(
21 | + ExTwitter.Config.get_tuples(),
22 | + access_token: user.access_token,
23 | + access_token_secret: user.access_token_secret
24 | + )
25 | + )
26 | +
27 | + ExTwitter.update(text)
28 | json(conn, %{})
29 | end
30 | end
31 | ```
32 | 我们从 `conn.assigns` 中读取 `current_user` 数据,然后动态配置 ExTwitter 的 `access_token` 及 `access_token_secret`,最后发送推文。
33 |
34 | 显然会报错,因为我们调用 `TweetBot.Accounts.get_user_by_from_id!` 方法 - 我们还没有定义过这个方法。
35 |
36 | 打开 `accounts.ex` 文件添加如下方法:
37 |
38 | .lib/tweet_bot/accounts/accounts.ex
39 | ```elixir
40 | + def get_user_by_from_id!(from_id) do
41 | + Repo.get_by!(User, from_id: from_id)
42 | + end
43 | ```
44 |
45 | 好了,现在给机器人发送内容,已经可以发送到 twitter 了。
--------------------------------------------------------------------------------
/notes/tweet-photo.asc:
--------------------------------------------------------------------------------
1 | = 发送图片
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | == Telegram 图片数据
8 |
9 | 我们给 telegram 机器人发送图片,webhook 会收到 https://core.telegram.org/bots/api#message[message] 数据,其中有一个 `photo` 字段罗列了 telegram 支持的该图片的所有尺寸,它大概长这样:
10 |
11 | ```elixir
12 | "photo" => [%{"file_id" => "AgADBQADI6gxG_qlmFUMyI1xrkLDad-o0zIABIuXxQXrM9uWjrIBAAEC", "file_size" => 1582, "height" => 76, "width" => 90}, %{"file_id" => "AgADBQADI6gxG_qlmFUMyI1xrkLDad-o0zIABJRMVi55JZ27j7IBAAEC", "file_size" => 16672, "height" => 270, "width" => 320}, %{"file_id" => "AgADBQADI6gxG_qlmFUMyI1xrkLDad-o0zIABPLMByqNSFYokLIBAAEC", "file_size" => 58871, "height" => 674, "width" => 800}, %{"file_id" => "AgADBQADI6gxG_qlmFUMyI1xrkLDad-o0zIABGV9HDHcMkzijbIBAAEC", "file_size" => 112278, "height" => 1078, "width" => 1280}]
13 | ```
14 |
15 | 因此我们要在 `twitter_controller.ex` 中新增一个 `index` 动作,专门处理这类情况:
16 |
17 | .lib/tweet_bot_web/controllers/twitter_controller.ex
18 | ```elixir
19 | def index(conn, %{"message" => %{"photo" => photo} = message}) do
20 |
21 | end
22 | ```
23 | 对我们来说,我们只要关心 `photo` 中最大的那张,其它尺寸可以不管:
24 |
25 | ```elixir
26 | photo |> Enum.at(-1)
27 | ```
28 | 这样我们就取得了最大的一张图片。
29 |
30 | 接下来我们要下载图片,然后调用 ExTwitter 接口发布图片。
31 |
32 | 然而在下载图片前,我们得调用 `telegram_bot` 的 `getFile` 方法,让 telegram 提前准备我们要下载的图片。
33 |
34 | == getFile
35 |
36 | 我们的代码这么写:
37 |
38 | .lib/tweet_bot_web/controllers/twitter_controller.ex
39 | ```elixir
40 | def index(conn, %{"message" => %{"photo" => photo} = message}) do
41 | case getFile(photo |> Enum.at(-1) |> Map.get("file_id")) do
42 | {:ok, file} ->
43 | %HTTPoison.Response{body: body} =
44 | HTTPoison.get!(
45 | "https://api.telegram.org/file/bot#{Application.get_env(:telegram_bot, :token)}/#{
46 | file |> Map.get("file_path")
47 | }",
48 | []
49 | )
50 |
51 | try do
52 | ExTwitter.update_with_media("", body)
53 | rescue
54 | e in ExTwitter.Error ->
55 | sendMessage(conn.assigns.current_user, "#{e.message}")
56 | end
57 |
58 | {:error, {_, reason}} ->
59 | sendMessage(conn.assigns.current_user, reason)
60 | end
61 |
62 | json(conn, %{})
63 | end
64 | ```
65 | 从 `photo` 获得最大尺寸的图片的 `file_id` 值后,我们调用 `getFile`,然后执行 `HTTPoison.get` 来下载图片,随后调用 `ExTwitter.update_with_media` 来发推。
66 |
67 | 你也许好奇为什么我可以在代码中直接使用 `HTTPoison` - 因为 `telegram_bot` 依赖中已经定义了。
68 |
69 | 但我们的代码中有个问题:上述代码只处理了单张图片不带 caption 的情况,还有单张图片带 caption、多张图片的情况。
70 |
71 | === 单张图片 + Caption
72 |
73 | 我们可以再定义一个 `index` 动作来处理这种情况:
74 |
75 | .lib/tweet_bot_web/controllers/twitter_controller.ex
76 | ```elixir
77 | # 单图,有 caption
78 | def index(conn, %{"message" => %{"photo" => photo, "caption" => caption}}) do
79 | json(conn, %{})
80 | end
81 | ```
82 | 但这样的话,函数体很大部分会重复。
83 |
84 | 最后我选择改造旧的 `index`:
85 |
86 | .lib/tweet_bot_web/controllers/twitter_controller.ex
87 | ```elixir
88 | - def index(conn, %{"message" => %{"photo" => photo}}) do
89 | + # 单图
90 | + def index(conn, %{"message" => %{"photo" => photo} = message}) do
91 | + caption = Map.get(message, "caption", "")
92 | +
93 | case getFile(photo |> Enum.at(-1) |> Map.get("file_id")) do
94 | {:ok, file} ->
95 | @@ -26,7 +27,7 @@ defmodule TweetBotWeb.TwitterController do
96 | )
97 |
98 | try do
99 | - ExTwitter.update_with_media("", body)
100 | + ExTwitter.update_with_media(caption, body)
101 | ```
102 |
103 | == Telegram file
104 |
105 | 除 `photo` 外,我们还可以以 `file` 的形式发送图片 - 匹别在于 `file` 的形式能够保留图片质量,而 `photo` 是会被压缩的。
106 |
107 | 我们以 `file` 形式发送一张 png 图片后,可以得到如下的数据结构:
108 |
109 | ```json
110 | "document": {
111 | "file_name": "Screen Shot 2018-03-15 at 5.06.33 PM.png",
112 | "mime_type": "image/png",
113 | "thumb": {
114 | "file_id": "AAQFABNB9dQyAATCqwat-kKWDMEwAAIC",
115 | "file_size": 2695,
116 | "width": 86,
117 | "height": 90
118 | },
119 | "file_id": "BQADBQADFgAD-qWYVQL_5lHSA-xKAg",
120 | "file_size": 300198
121 | }
122 | ```
123 | 我需要处理 `file` 形式发送的图片,包括 png、jpg、jpeg、gif,至于其它格式,比如 pdf 等,就不在我们考虑中了。
124 |
125 | 这一次,我们要新增一个 `index` 动作:
126 |
127 | .lib/tweet_bot_web/controllers/twitter_controller.ex
128 | ```elixir
129 | # 处理 file 形式的图片
130 | def index(conn, %{
131 | "message" => %{"document" => %{"mime_type" => mime_type} = document} = message
132 | })
133 | when mime_type in ["image/png", "image/jpeg", "image/gif"] do
134 | caption = Map.get(message, "caption", "")
135 | case getFile(Map.get(document, "file_id")) do
136 | {:ok, file} ->
137 | %HTTPoison.Response{body: body} =
138 | HTTPoison.get!(
139 | "https://api.telegram.org/file/bot#{Application.get_env(:telegram_bot, :token)}/#{
140 | file |> Map.get("file_path")
141 | }",
142 | []
143 | )
144 |
145 | try do
146 | ExTwitter.update_with_media(caption, body)
147 | rescue
148 | e in ExTwitter.Error ->
149 | sendMessage(conn.assigns.current_user, "#{e.message}")
150 | end
151 |
152 | {:error, {_, reason}} ->
153 | sendMessage(conn.assigns.current_user, reason)
154 | end
155 |
156 | json(conn, %{})
157 | end
158 | ```
159 |
160 | 你可以看到,新增的 `index` 跟处理 `photo` 的 `index` 有大量重复代码,很简单,我们将重复代码提取成函数:
161 |
162 | .lib/tweet_bot_web/controllers/twitter_controller.ex
163 | ```elixir
164 | defp tweet_photo(conn, file, caption) do
165 | try do
166 | %HTTPoison.Response{body: body} =
167 | HTTPoison.get!(
168 | "https://api.telegram.org/file/bot#{Application.get_env(:telegram_bot, :token)}/#{
169 | file |> Map.get("file_path")
170 | }",
171 | []
172 | )
173 |
174 | ExTwitter.update_with_media(caption, body)
175 | json(conn, %{})
176 | rescue
177 | e in ExTwitter.Error ->
178 | {:error, {:extwitter, e.message}}
179 |
180 | e in HTTPoison.Error ->
181 | {:error, {:httpoison, e.reason}}
182 | end
183 | end
184 | ```
185 | 然后去掉重复代码,将它们用 `tweet_photo` 函数替换。
--------------------------------------------------------------------------------
/notes/twitter-oauth.asc:
--------------------------------------------------------------------------------
1 | = 与 Twitter 接口通信
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 首先访问 https://apps.twitter.com[https://apps.twitter.com] 新建一个 app,这样我们就有了两个数据:
8 |
9 | 1. Consumer Key
10 | 2. Consumer Secret
11 |
12 | 记得配置 https://developer.twitter.com/en/docs/basics/developer-portal/guides/callback-urls.html[`Callback URLs`] - 比如开发环境下我将新建一个 `/auth_callback` 路由用于处理回调,就需要将 `http://127.0.0.1:4000/auth_callback` 加入 `Callback URLs`。
13 |
14 | 接下来与 twitter 的一切通信都交给 https://github.com/parroty/extwitter[https://github.com/parroty/extwitter] 库。
15 |
16 | 在 `mix.exs` 文件的 `deps` 中新增 `{:extwitter, "~> 0.9.3"}`,并执行 `mix deps.get` 安装 `extwitter`。
17 |
18 | 安装完 `extwitter` 后,按说明在 `dev.exs` 添加如下配置(记得要重启 Phoenix):
19 |
20 | .config/dev.exs
21 | ```elixir
22 | +
23 | +# Configures extwitter oauth
24 | +config :extwitter, :oauth, [
25 | + consumer_key: System.get_env("TWITTER_CONSUMER_KEY"),
26 | + consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET")
27 | +]
28 | ```
29 | 我们从环境变量中读取 `consumer_key` 与 `consumer_secret` 的值,`access_token` 与 `access_token_secret` 值暂时不设定,后面在代码中动态设置。
30 |
31 | 不过等一等,`access_token_secret`?我在 `User` 结构中可是只定义了 `access_token`。
32 |
33 | 显然,我们需要在 `User` 结构中新增 `access_token_secret` 字段。
34 |
35 | ## mix ecto.gen.migration
36 |
37 | 我们要借助 https://hexdocs.pm/phoenix/phoenix_mix_tasks.html#ecto-specific-mix-tasks[`mix ecto.gen.migration`]。
38 |
39 | 在命令行下执行:
40 |
41 | ```sh
42 | $ mix ecto.gen.migration add_access_token_secret_to_users
43 | Generated tweet_bot app
44 | * creating priv/repo/migrations/20181203122738_add_access_token_secret_to_users.exs
45 | ```
46 | 打开新建的文件,目前内容如下:
47 |
48 | .priv/repo/migrations/20181203122738_add_access_token_secret_to_users.exs
49 | ```elixir
50 | defmodule TweetBot.Repo.Migrations.AddAccessTokenSecretToUsers do
51 | use Ecto.Migration
52 |
53 | def change do
54 |
55 | end
56 | end
57 | ```
58 | 调整如下:
59 |
60 | ```
61 | def change do
62 | -
63 | + alter table(:users) do
64 | + add(:access_token_secret, :string)
65 | + end
66 | end
67 | ```
68 | 此外,我们还需要调整 `user.ex`:
69 |
70 | ```elixir
71 | defmodule TweetBot.Accounts.User do
72 | use Ecto.Schema
73 | import Ecto.Changeset
74 |
75 | schema "users" do
76 | field(:access_token, :string)
77 | + field(:access_token_secret, :string)
78 | field(:from_id, :string)
79 |
80 | timestamps()
81 | end
82 |
83 | @doc false
84 | def changeset(user, attrs) do
85 | user
86 | - |> cast(attrs, [:from_id, :access_token])
87 | + |> cast(attrs, [:from_id, :access_token, :access_token_secret])
88 | end
89 | end
90 | ```
91 |
92 | 接着运行 `mix ecto.migrate` 来让上述修改生效。
93 |
94 | == 回调
95 |
96 | 再回到 https://dev.twitter.com/web/sign-in/implementing[twitter OAuth 流程],我们需要提交一个回调地址,这样用户登录后,twitter 会跳到该回调 - 并且携带 `oauth_token` 及 `oauth_verifier`,接着我们再提交这两个参数去换取 `access_token` 及 `access_token_secret`。
97 |
98 | 我们需要在 `router.ex` 中新增一个路由:
99 |
100 | .lib/tweet_bot_web/router.ex
101 | ```elixir
102 | scope "/", TweetBotWeb do
103 | pipe_through :browser # Use the default browser stack
104 |
105 | get("/", PageController, :index)
106 | + get("/auth_callback", AuthController, :callback)
107 | end
108 | ```
109 | 接着创建 `lib/tweet_bot_web/controllers/auth_controller.ex` 文件:
110 |
111 | .lib/tweet_bot_web/controllers/auth_controller.ex
112 | ```elixir
113 | +defmodule TweetBotWeb.AuthController do
114 | + use TweetBotWeb, :controller
115 | +
116 | + def callback(conn, _params) do
117 | + end
118 | +end
119 | ```
120 |
121 | == 启动 OAuth
122 |
123 | 我们启动 OAuth 流程的时机是在接收到用户发来 `/start`。所以让我们回到 `twitter_controller.ex` 中,调整代码如下:
124 |
125 | ```elixir
126 | def index(conn, %{"message" => %{"from" => %{"id" => from_id}, "text" => text}}) do
127 | - sendMessage(from_id, "你好")
128 | + case text do
129 | + "/start" ->
130 | + token =
131 | + ExTwitter.request_token(
132 | + URI.encode_www_form(Routes.auth_url(conn, :callback))
133 | + )
134 | +
135 | + {:ok, authenticate_url} = ExTwitter.authenticate_url(token.oauth_token)
136 | +
137 | + sendMessage(
138 | + from_id,
139 | + "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter",
140 | + parse_mode: "HTML"
141 | + )
142 | +
143 | + _ ->
144 | + sendMessage(from_id, "你好")
145 | + end
146 | +
147 | json(conn, %{})
148 | end
149 | ```
150 | 尝试给测试机器发送 `/start`,好一会儿,开发服务器下报告错误:
151 |
152 | ```sh
153 | [error] #PID<0.474.0> running TweetBotWeb.Endpoint terminated
154 | Request: POST /api/twitter
155 | ** (exit) an exception was raised:
156 | ** (MatchError) no match of right hand side value: {:error, {:failed_connect, [{:to_address, {'api.twitter.com', 443}}, {:inet, [:inet], :etimedout}]}}
157 | (extwitter) lib/extwitter/api/auth.ex:10: ExTwitter.API.Auth.request_token/1
158 | (tweet_bot) lib/tweet_bot_web/controllers/twitter_controller.ex:9: TweetBotWeb.TwitterController.index/2
159 | (tweet_bot) lib/tweet_bot_web/controllers/twitter_controller.ex:1: TweetBotWeb.TwitterController.action/2
160 | (tweet_bot) lib/tweet_bot_web/controllers/twitter_controller.ex:1: TweetBotWeb.TwitterController.phoenix_controller_pipeline/2
161 | (tweet_bot) lib/tweet_bot_web/endpoint.ex:1: TweetBotWeb.Endpoint.instrument/4
162 | (phoenix) lib/phoenix/router.ex:278: Phoenix.Router.__call__/1
163 | (tweet_bot) lib/tweet_bot_web/endpoint.ex:1: TweetBotWeb.Endpoint.plug_builder_call/2
164 | (tweet_bot) lib/plug/debugger.ex:102: TweetBotWeb.Endpoint."call (overridable 3)"/2
165 | (tweet_bot) lib/tweet_bot_web/endpoint.ex:1: TweetBotWeb.Endpoint.call/2
166 | (plug) lib/plug/adapters/cowboy/handler.ex:16: Plug.Adapters.Cowboy.Handler.upgrade/4
167 | (cowboy) /Users/sam/Documents/githubRepos/tweet_bot/deps/cowboy/src/cowboy_protocol.erl:442: :cowboy_protocol.execute/4
168 | ```
169 | 是了,`extwitter` 要与 twitter 通信,同样需要配置代理。打开 `dev.exs` 文件,新增如下内容:
170 |
171 | .config/dev.exs
172 | ```elixir
173 | +]
174 | +
175 | +# Configures extwitter proxy
176 | +config :extwitter, :proxy, [
177 | + server: "127.0.0.1",
178 | + port: 1087
179 | ]
180 | ```
181 | 重启开发服务器。再发送 `/start` 给机器人 - 收到登录链接了。
182 |
183 | 不过且慢点击登录链接。在点击前,我们还需要填充 `auth_controller.ex` 中的 `callback`:
184 |
185 | ```elixir
186 |
187 | - def callback(conn, _params) do
188 | + def callback(conn, %{"oauth_token" => oauth_token, "oauth_verifier" => oauth_verifier}) do
189 | + # 获取 access token
190 | + {:ok, token} = ExTwitter.access_token(oauth_verifier, oauth_token)
191 | + IO.inspect(token)
192 | + text(conn, "授权成功,请关闭此页面")
193 | end
194 | ```
195 | 跑一遍流程就会发现,我们已经成功获取到 `access_token` 与 `access_token_secret` 了 - 只不过,响应中的名称与我们预想中的不一样,一个是 `oauth_token`,一个是 `oauth_token_secret`。拿到这俩个数据后,我们就可以以用户的身份发推了:
196 |
197 | ```elixir
198 | {:ok, token} = ExTwitter.access_token(oauth_verifier, oauth_token)
199 | - IO.inspect(token)
200 | + ExTwitter.configure(
201 | + :process,
202 | + Enum.concat(
203 | + ExTwitter.Config.get_tuples,
204 | + [ access_token: token.oauth_token,
205 | + access_token_secret: token.oauth_token_secret ]
206 | + )
207 | + )
208 | + ExTwitter.update("I just sign up telegram bot tweet_for_me_bot.")
209 | text(conn, "授权成功,请关闭此页面")
210 | ```
211 | 发送 `/start` 给机器人,点击返回的链接,授权,查看 twitter 主页,有了:`I just sign up telegram bot tweet_for_me_bot.`。
--------------------------------------------------------------------------------
/notes/user-test.asc:
--------------------------------------------------------------------------------
1 | = 测试 `User`
2 | 陈三
3 | :!webfonts:
4 | :source-highlighter: pygments
5 |
6 | 老实讲,我不大喜欢写测试。
7 |
8 | 但我还是得说,测试非常重要。它们是我们修改代码的灯塔 - 没有它们,我们很可能在修改代码时触礁。只是大部分项目活不过几个迭代 - 也就没多大必要写测试。
9 |
10 | 针对 `User`,有两个要点需要测试:
11 |
12 | 1. `from_id` 必填
13 | 2. `from_id` 独一无二
14 |
15 | 在 `test/tweet_bot/accounts/accounts_test.exs` 文件中新增测试如下:
16 |
17 | .test/tweet_bot/accounts/accounts_test.exs
18 | ```elixir
19 | test "from_id should be required" do
20 | changeset = User.changeset(%User{}, @valid_attrs |> Map.delete(:from_id))
21 | refute changeset.valid?
22 | assert %{from_id: ["can't be blank"]} = errors_on(changeset)
23 | end
24 | test "from_id should be unique" do
25 | assert {:ok, _} = Accounts.create_user(@valid_attrs)
26 | assert {:error, changeset} = Accounts.create_user(@valid_attrs)
27 | assert %{from_id: ["has already been taken"]} = errors_on(changeset)
28 | end
29 | ```
30 | 运行 `mix test`:
31 |
32 | ```sh
33 | $ mix test
34 | .............
35 |
36 | Finished in 0.2 seconds
37 | 13 tests, 0 failures
38 |
39 | Randomized with seed 202657
40 | ```
41 |
42 | 是了,这就是测试驱动开发。我们的测试使用我们的接口,并验证接口行为的正确性。而我们的代码将根据测试结果不断调整,直到测试全部通过。
--------------------------------------------------------------------------------
/notes/who-is-that.asc:
--------------------------------------------------------------------------------
1 | = 用户授权了吗?
2 | 陈三
3 | :!webfonts:
4 | :icons: font
5 | :source-highlighter: pygments
6 |
7 | 在我的设计里,所有的消息,是一定都要检查该用户是否已授权的。
8 |
9 | 这种场景非常适合 https://hexdocs.pm/phoenix/plug.html[Plug] 来处理,这里我们在 `twitter_controller.ex` 文件里新增一个 function plug。
10 |
11 | .lib/tweet_bot_web/controllers/twitter_controller.ex
12 | ```elixir
13 | alias TweetBot.Accounts
14 | plug(:find_user)
15 |
16 | defp find_user(conn, _) do
17 | %{"message" => %{"from" => %{"id" => from_id}}} = conn.params
18 |
19 | case Accounts.get_user_by_from_id(from_id) do
20 | user when not is_nil(user) ->
21 | assign(conn, :current_user, user.from_id)
22 |
23 | nil ->
24 | token =
25 | ExTwitter.request_token(
26 | URI.encode_www_form(
27 | Routes.auth_url(conn, :callback) <> "?from_id=#{from_id}"
28 | )
29 | )
30 |
31 | {:ok, authenticate_url} = ExTwitter.authenticate_url(token.oauth_token)
32 |
33 | sendMessage(
34 | from_id,
35 | "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter",
36 | parse_mode: "HTML"
37 | )
38 |
39 | conn |> halt()
40 | end
41 | end
42 | ```
43 |
44 | 现在会报 `Accounts.get_user_by_from_id` 未找到的错误,因为我们还没有定义它。
45 |
46 | 打开 `accounts.ex` 文件,添加方法:
47 |
48 | .lib/tweet_bot/accounts/accounts.ex
49 | ```elixir
50 | def get_user_by_from_id(from_id) do
51 | Repo.get_by(User, from_id: from_id)
52 | end
53 | ```
54 | 不过还是会报错:
55 |
56 | > [debug] ** (Ecto.Query.CastError) deps/ecto/lib/ecto/repo/queryable.ex:357: value `48885097` in `where` cannot be cast to type :string in query:
57 |
58 | 这是因为我们在定义 `User` 时,`from_id` 是一个字符串,而 `conn.params` 中解析出的却是数值。我们可以粗暴一点,直接做类型转换:
59 |
60 | ```elixir
61 | Repo.get_by(User, from_id: Integer.to_string(from_id))
62 | ```
63 | 但长远来说,这只是个 workaround,不是真正的解决办法。
64 |
65 | 下面我们将通过 migration 调整 `User` 中 `from_id` 的类型。
66 |
67 | == Migration
68 |
69 | 创建一个 migration:
70 |
71 | ```sh
72 | $ mix ecto.gen.migration alter_users
73 | * creating priv/repo/migrations/20181203125626_alter_users.exs
74 | ```
75 | 打开新建的文件,修改内容如下:
76 |
77 | .priv/repo/migrations/20181203125626_alter_users.exs
78 | ```elixir
79 | +defmodule TweetBot.Repo.Migrations.AlterUsers do
80 | + use Ecto.Migration
81 | +
82 | + def change do
83 | + alter table(:users) do
84 | + modify(:from_id, :integer)
85 | + end
86 | + end
87 | +end
88 | ```
89 | 运行 `mix ecto.migrate`:
90 |
91 | ```sh
92 | $ mix ecto.migrate
93 | [info] == Running TweetBot.Repo.Migrations.AlterUsers.change/0 forward
94 | [info] alter table users
95 | ** (Postgrex.Error) ERROR 42804 (datatype_mismatch): column "from_id" cannot be cast automatically to type integer
96 | ```
97 | 报错了。我怀疑是不是因为数据库中已经有数据导致的,就删库重试:
98 |
99 | ```sh
100 | $ mix ecto.drop
101 | $ mix ecto.create
102 | $ mix ecto.migrate
103 | ```
104 | 仍然报错。Google 扫了一圈没找到答案,只好到 https://elixirforum.com/t/postgrex-error-error-42804-datatype-mismatch-column-cannot-be-cast-automatically-to-type-integer/16776[elixir forum 提问],好了,有回复:
105 |
106 | ```elixir
107 | def change do
108 | execute(
109 | "alter table users alter column from_id type integer using (from_id::integer)",
110 | "alter table users alter column from_id type character varying(255)"
111 | )
112 | end
113 | ```
114 | 重新运行 `mix ecto.migrate`,成功。
115 |
116 | 此外还要调整下 `user.ex` 文件:
117 |
118 | ```elixir
119 | field :access_token, :string
120 | - field :from_id, :string
121 | + field :from_id, :integer
122 | field :access_token_secret, :string
123 | ```
124 | 因为我们调整了 `:from_id` 的类型,可以预计,`mix test` 一定会报错。
125 |
126 | 不过修复起来也很简单,打开 `accounts_test.exs` 文件,将 `from_id` 从字符串改为数值:
127 |
128 | .test/tweet_bot/accounts/accounts_test.exs
129 | ```elixir
130 | - @valid_attrs %{access_token: "some access_token", from_id: "some from_id"}
131 | - @update_attrs %{access_token: "some updated access_token", from_id: "some updated from_id"}
132 | + @valid_attrs %{access_token: "some access_token", from_id: 1}
133 | + @update_attrs %{access_token: "some updated access_token", from_id: 2}
134 | @invalid_attrs %{access_token: nil, from_id: nil}
135 |
136 | def user_fixture(attrs \\ %{}) do
137 | @@ -32,7 +32,7 @@ defmodule TweetBot.AccountsTest do
138 | test "create_user/1 with valid data creates a user" do
139 | assert {:ok, %User{} = user} = Accounts.create_user(@valid_attrs)
140 | assert user.access_token == "some access_token"
141 | - assert user.from_id == "some from_id"
142 | + assert user.from_id == 1
143 | end
144 |
145 | test "create_user/1 with invalid data returns error changeset" do
146 | @@ -44,7 +44,7 @@ defmodule TweetBot.AccountsTest do
147 | assert {:ok, user} = Accounts.update_user(user, @update_attrs)
148 | assert %User{} = user
149 | assert user.access_token == "some updated access_token"
150 | - assert user.from_id == "some updated from_id"
151 | + assert user.from_id == 2
152 | end
153 | ```
154 | 再运行 `mix test`,悉数通过。
155 |
156 | == 优化代码
157 |
158 | 在添加上述 Plug 后,我们可以对 `twitter_controller.ex` 中的 `index` 动作做进一步优化:
159 |
160 | .lib/tweet_bot_web/controllers/twitter_controller.ex
161 | ```elixir
162 | plug :find_user
163 |
164 | - def index(conn, %{"message" => %{"from" => %{"id" => from_id}, "text" => text}}) do
165 | - case text do
166 | - "/start" ->
167 | - token = ExTwitter.request_token(URI.encode_www_form(Routers.auth_url(conn, :callback) <> "?from_id=#{from_id}"))
168 | - {:ok, authenticate_url} = ExTwitter.authenticate_url(token.oauth_token)
169 | - sendMessage(from_id, "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter", parse_mode: "HTML")
170 | - _ -> sendMessage(from_id, "你好")
171 | - end
172 | + def index(conn, %{"message" => %{"text" => "/start"}}) do
173 | + sendMessage(conn.assigns.current_user, "已授权,请直接发送消息")
174 | + json(conn, %{})
175 | + end
176 | +
177 | + def index(conn, _) do
178 | json(conn, %{})
179 | end
180 | ```
181 | 是了,这里展示的正是模式匹配的优美。我们可以在一个 controller 文件里写多个同名 `index` 动作,每个动作处理不同的参数 - 不必在一个巨大的 `index` 动作中又是 `if` `else` 又是 `case do` 了。
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This file is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here as no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180921014945_create_users.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Repo.Migrations.CreateUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:users) do
6 | add :from_id, :string
7 | add :access_token, :string
8 |
9 | timestamps()
10 | end
11 |
12 | create unique_index(:users, [:from_id])
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180921113934_add_access_token_secret_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Repo.Migrations.AddAccessTokenSecretToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add(:access_token_secret, :string)
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180921133030_alter_users.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.Repo.Migrations.AlterUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute(
6 | "alter table users alter column from_id type integer using (from_id::integer)",
7 | "alter table users alter column from_id type character varying(255)"
8 | )
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # TweetBot.Repo.insert!(%TweetBot.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/rel/env.bat.eex:
--------------------------------------------------------------------------------
1 | @echo off
2 | rem Set the release to work across nodes. If using the long name format like
3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the
4 | rem RELEASE_DISTRIBUTION variable below.
5 | rem set RELEASE_DISTRIBUTION=name
6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1
7 |
--------------------------------------------------------------------------------
/rel/env.sh.eex:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Sets and enables heart (recommended only in daemon mode)
4 | # case $RELEASE_COMMAND in
5 | # daemon*)
6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND"
7 | # export HEART_COMMAND
8 | # export ELIXIR_ERL_OPTIONS="-heart"
9 | # ;;
10 | # *)
11 | # ;;
12 | # esac
13 |
14 | # Set the release to work across nodes. If using the long name format like
15 | # the one below (my_app@127.0.0.1), you need to also uncomment the
16 | # RELEASE_DISTRIBUTION variable below.
17 | # export RELEASE_DISTRIBUTION=name
18 | # export RELEASE_NODE=<%= @release.name %>@127.0.0.1
--------------------------------------------------------------------------------
/rel/vm.args.eex:
--------------------------------------------------------------------------------
1 | ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html
2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here
3 |
4 | ## Number of dirty schedulers doing IO work (file, sockets, etc)
5 | ##+SDio 5
6 |
7 | ## Increase number of concurrent ports/sockets
8 | ##+Q 65536
9 |
10 | ## Tweak GC to run more often
11 | ##-env ERL_FULLSWEEP_AFTER 10
12 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common datastructures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with channels
21 | use Phoenix.ChannelTest
22 |
23 | # The default endpoint for testing
24 | @endpoint TweetBotWeb.Endpoint
25 | end
26 | end
27 |
28 |
29 | setup tags do
30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(TweetBot.Repo)
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(TweetBot.Repo, {:shared, self()})
33 | end
34 | :ok
35 | end
36 |
37 | end
38 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common datastructures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with connections
21 | use Phoenix.ConnTest
22 | import TweetBotWeb.Router.Helpers
23 |
24 | # The default endpoint for testing
25 | @endpoint TweetBotWeb.Endpoint
26 | end
27 | end
28 |
29 |
30 | setup tags do
31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(TweetBot.Repo)
32 | unless tags[:async] do
33 | Ecto.Adapters.SQL.Sandbox.mode(TweetBot.Repo, {:shared, self()})
34 | end
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 |
38 | end
39 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | using do
18 | quote do
19 | alias TweetBot.Repo
20 |
21 | import Ecto
22 | import Ecto.Changeset
23 | import Ecto.Query
24 | import TweetBot.DataCase
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(TweetBot.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(TweetBot.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 |
38 | @doc """
39 | A helper that transform changeset errors to a map of messages.
40 |
41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
42 | assert "password is too short" in errors_on(changeset).password
43 | assert %{password: ["password is too short"]} = errors_on(changeset)
44 |
45 | """
46 | def errors_on(changeset) do
47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
48 | Enum.reduce(opts, message, fn {key, value}, acc ->
49 | String.replace(acc, "%{#{key}}", to_string(value))
50 | end)
51 | end)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/test/support/mocks.ex:
--------------------------------------------------------------------------------
1 | Mox.defmock(TwitterMock, for: Twitter)
2 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
3 | Ecto.Adapters.SQL.Sandbox.mode(TweetBot.Repo, :manual)
4 |
--------------------------------------------------------------------------------
/test/tweet_bot/accounts/accounts_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBot.AccountsTest do
2 | use TweetBot.DataCase
3 |
4 | alias TweetBot.Accounts
5 |
6 | describe "users" do
7 | alias TweetBot.Accounts.User
8 |
9 | @valid_attrs %{access_token: "some access_token", from_id: 1}
10 | @update_attrs %{access_token: "some updated access_token", from_id: 2}
11 | @invalid_attrs %{access_token: nil, from_id: nil}
12 |
13 | test "from_id should be required" do
14 | changeset = User.changeset(%User{}, @valid_attrs |> Map.delete(:from_id))
15 | refute changeset.valid?
16 | assert %{from_id: ["can't be blank"]} = errors_on(changeset)
17 | end
18 |
19 | test "from_id should be unique" do
20 | assert {:ok, _} = Accounts.create_user(@valid_attrs)
21 | assert {:error, changeset} = Accounts.create_user(@valid_attrs)
22 | assert %{from_id: ["has already been taken"]} = errors_on(changeset)
23 | end
24 |
25 | def user_fixture(attrs \\ %{}) do
26 | {:ok, user} =
27 | attrs
28 | |> Enum.into(@valid_attrs)
29 | |> Accounts.create_user()
30 |
31 | user
32 | end
33 |
34 | test "list_users/0 returns all users" do
35 | user = user_fixture()
36 | assert Accounts.list_users() == [user]
37 | end
38 |
39 | test "get_user!/1 returns the user with given id" do
40 | user = user_fixture()
41 | assert Accounts.get_user!(user.id) == user
42 | end
43 |
44 | test "create_user/1 with valid data creates a user" do
45 | assert {:ok, %User{} = user} = Accounts.create_user(@valid_attrs)
46 | assert user.access_token == "some access_token"
47 | assert user.from_id == 1
48 | end
49 |
50 | test "create_user/1 with invalid data returns error changeset" do
51 | assert {:error, %Ecto.Changeset{}} = Accounts.create_user(@invalid_attrs)
52 | end
53 |
54 | test "update_user/2 with valid data updates the user" do
55 | user = user_fixture()
56 | assert {:ok, user} = Accounts.update_user(user, @update_attrs)
57 | assert %User{} = user
58 | assert user.access_token == "some updated access_token"
59 | assert user.from_id == 2
60 | end
61 |
62 | test "update_user/2 with invalid data returns error changeset" do
63 | user = user_fixture()
64 | assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, @invalid_attrs)
65 | assert user == Accounts.get_user!(user.id)
66 | end
67 |
68 | test "delete_user/1 deletes the user" do
69 | user = user_fixture()
70 | assert {:ok, %User{}} = Accounts.delete_user(user)
71 | assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) end
72 | end
73 |
74 | test "change_user/1 returns a user changeset" do
75 | user = user_fixture()
76 | assert %Ecto.Changeset{} = Accounts.change_user(user)
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/test/tweet_bot_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.PageControllerTest do
2 | use TweetBotWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get conn, "/"
6 | assert html_response(conn, 200) =~ "Hello Tweet for me bot!"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/tweet_bot_web/controllers/twitter_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.TwitterControllerTest do
2 | use TweetBotWeb.ConnCase
3 | import Mox
4 |
5 | setup :verify_on_exit!
6 |
7 | @valid_message %{
8 | "message" => %{
9 | "from" => %{
10 | "id" => 123
11 | },
12 | "text" => "/start"
13 | }
14 | }
15 |
16 | test "POST /api/twitter with /start first time", %{conn: conn} do
17 | TwitterMock
18 | |> expect(:request_token, fn _ -> %{oauth_token: ""} end)
19 | |> expect(:authenticate_url, fn _ -> {:ok, "https://blog.zfanw.com"} end)
20 |
21 | conn = post(conn, "/api/twitter", @valid_message)
22 |
23 | assert json_response(conn, 200) == %{
24 | "chat_id" => 123,
25 | "method" => "sendMessage",
26 | "parse_mode" => "HTML",
27 | "text" => "请点击链接登录您的 Twitter 账号进行授权:登录 Twitter"
28 | }
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/tweet_bot_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.ErrorViewTest do
2 | use TweetBotWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(TweetBotWeb.ErrorView, "404.html", []) ==
9 | "Not Found"
10 | end
11 |
12 | test "renders 500.html" do
13 | assert render_to_string(TweetBotWeb.ErrorView, "500.html", []) ==
14 | "Internal Server Error"
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/tweet_bot_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.LayoutViewTest do
2 | use TweetBotWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/test/tweet_bot_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TweetBotWeb.PageViewTest do
2 | use TweetBotWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------