pocket_phone 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE +28 -0
  4. data/README.md +184 -0
  5. data/app/controllers/pocket_phone/api/sendblue_controller.rb +329 -0
  6. data/app/controllers/pocket_phone/application_controller.rb +33 -0
  7. data/app/controllers/pocket_phone/conversations_controller.rb +91 -0
  8. data/app/controllers/pocket_phone/media_controller.rb +14 -0
  9. data/app/controllers/pocket_phone/messages_controller.rb +65 -0
  10. data/app/controllers/pocket_phone/people_controller.rb +19 -0
  11. data/app/helpers/pocket_phone/application_helper.rb +145 -0
  12. data/app/views/layouts/pocket_phone/_script.html.erb +183 -0
  13. data/app/views/layouts/pocket_phone/_styles.html.erb +192 -0
  14. data/app/views/layouts/pocket_phone/application.html.erb +15 -0
  15. data/app/views/pocket_phone/conversations/_head.html.erb +44 -0
  16. data/app/views/pocket_phone/conversations/_message.html.erb +132 -0
  17. data/app/views/pocket_phone/conversations/_messages.html.erb +21 -0
  18. data/app/views/pocket_phone/conversations/_new_dialog.html.erb +52 -0
  19. data/app/views/pocket_phone/conversations/_sidebar.html.erb +28 -0
  20. data/app/views/pocket_phone/conversations/show.html.erb +67 -0
  21. data/config/routes.rb +41 -0
  22. data/lib/pocket_phone/carrier.rb +119 -0
  23. data/lib/pocket_phone/configuration.rb +60 -0
  24. data/lib/pocket_phone/database.rb +157 -0
  25. data/lib/pocket_phone/engine.rb +9 -0
  26. data/lib/pocket_phone/link_preview.rb +128 -0
  27. data/lib/pocket_phone/numbers.rb +40 -0
  28. data/lib/pocket_phone/payloads.rb +50 -0
  29. data/lib/pocket_phone/runner.rb +37 -0
  30. data/lib/pocket_phone/store.rb +106 -0
  31. data/lib/pocket_phone/version.rb +5 -0
  32. data/lib/pocket_phone/webhook.rb +68 -0
  33. data/lib/pocket_phone.rb +47 -0
  34. metadata +135 -0
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ class ConversationsController < ApplicationController
5
+ def index
6
+ @db = store.read
7
+ latest = @db.sorted_conversations.first
8
+ return redirect_to(conversation_path(latest["id"])) if latest && params[:list].blank?
9
+
10
+ render :show
11
+ end
12
+
13
+ def show
14
+ load_conversation(params[:id])
15
+ redirect_to root_path unless @conversation
16
+ end
17
+
18
+ # What the page polls. Answers 204 while nothing has changed, and the
19
+ # freshly rendered pieces of the page when something has.
20
+ def state
21
+ load_conversation(params[:conversation])
22
+ token = "#{@db.version}:#{@db.typing?(@conversation) ? 1 : 0}"
23
+ return head(:no_content) if params[:version] == token
24
+
25
+ render json: {
26
+ version: token,
27
+ gone: params[:conversation].present? && @conversation.nil?,
28
+ sidebar: render_to_string(partial: "pocket_phone/conversations/sidebar", formats: [:html]),
29
+ head: @conversation && render_to_string(partial: "pocket_phone/conversations/head", formats: [:html]),
30
+ messages: @conversation && render_to_string(partial: "pocket_phone/conversations/messages", formats: [:html])
31
+ }
32
+ end
33
+
34
+ # A phone starts a conversation with the app's line: one person, or a
35
+ # group of them.
36
+ def create
37
+ conversation = store.write do |db|
38
+ line = Numbers.normalize(params[:line]).presence || default_line(db)
39
+ db.ensure_line(line)
40
+ params[:group].present? ? create_group(db, line) : create_direct(db, line)
41
+ end
42
+ redirect_to(conversation ? conversation_path(conversation["id"]) : root_path)
43
+ end
44
+
45
+ def typing
46
+ conversation = store.read.conversations[params[:id]]
47
+ if conversation && conversation["kind"] == "direct"
48
+ Carrier.typing_to_app(conversation, is_typing: params[:is_typing].to_s != "false")
49
+ end
50
+ head :no_content
51
+ end
52
+
53
+ def destroy
54
+ store.write { |db| db.delete_conversation(params[:id]) }
55
+ redirect_to root_path
56
+ end
57
+
58
+ def clear
59
+ store.clear!
60
+ redirect_to root_path
61
+ end
62
+
63
+ private
64
+
65
+ def load_conversation(id)
66
+ @db = store.read
67
+ @conversation = id.present? ? @db.conversations[id] : nil
68
+ return unless @conversation && @conversation["unread"].to_i.positive?
69
+
70
+ # Looking at a thread is reading it.
71
+ store.write { |db| db.conversations[id]&.store("unread", 0) }
72
+ @db = store.read
73
+ @conversation = @db.conversations[id]
74
+ end
75
+
76
+ def create_direct(db, line)
77
+ number = Numbers.normalize(params[:number])
78
+ return nil if number.empty?
79
+
80
+ db.ensure_person(number, "name" => params[:name], "service" => params[:service])
81
+ db.direct(line, number)
82
+ end
83
+
84
+ def create_group(db, line)
85
+ participants = Array(params[:participants]).map { |n| Numbers.normalize(n) }.reject(&:empty?).uniq
86
+ return nil if participants.size < 2
87
+
88
+ db.create_group(line: line, participants: participants, name: params[:group_name])
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ # Attachments sent from the phone, or uploaded through the fake API. Served
5
+ # from here so a media_url in a webhook is one the app can really fetch.
6
+ class MediaController < ApplicationController
7
+ def show
8
+ file = store.media_file(params[:name])
9
+ return head(:not_found) unless file
10
+
11
+ send_file file, disposition: "inline"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ # What the person holding the phone does: text, tapback, try again.
5
+ class MessagesController < ApplicationController
6
+ def create
7
+ upload = params[:file].presence
8
+ media_url = upload && media_url_for(store.save_media(upload, upload.original_filename))
9
+ content = params[:content].to_s.strip
10
+ return head(:unprocessable_entity) if content.empty? && media_url.nil?
11
+
12
+ message = store.write do |db|
13
+ conversation = db.conversations[params[:id]] or next
14
+ from = sender(db, conversation) or next
15
+ db.add_message(conversation, "direction" => "phone", "status" => "RECEIVED",
16
+ "from_number" => from, "to_number" => conversation["line"],
17
+ "content" => content, "media_url" => media_url, "service" => db.service_for(conversation))
18
+ end
19
+ return head(:not_found) unless message
20
+
21
+ Carrier.deliver_to_app(message["handle"])
22
+ Carrier.fetch_preview(message["handle"])
23
+ head :no_content
24
+ end
25
+
26
+ # A tapback on something the app sent. Tapping the same one again takes it
27
+ # back, as on a phone.
28
+ def react
29
+ reaction = params[:reaction].to_s
30
+ outcome, by = store.write do |db|
31
+ message = db.message(params[:handle]) or next
32
+ conversation = db.conversations[message["conversation_id"]] or next
33
+ next unless message["direction"] == "app" && message["service"] == "iMessage"
34
+
35
+ by = sender(db, conversation) or next
36
+ [db.react(message, by: by, reaction: reaction), by]
37
+ end
38
+ return head(:unprocessable_entity) unless outcome
39
+
40
+ Carrier.reaction_to_app(params[:handle], by: by, reaction: reaction) if outcome == :added
41
+ head :no_content
42
+ end
43
+
44
+ # Send the receive webhook again, as Sendblue does after a 5xx. Handy for
45
+ # checking that a redelivery is not answered twice.
46
+ def redeliver
47
+ message = store.read.message(params[:handle])
48
+ return head(:not_found) unless message && message["direction"] == "phone"
49
+
50
+ Carrier.deliver_to_app(message["handle"])
51
+ head :no_content
52
+ end
53
+
54
+ private
55
+
56
+ # Who is talking. A one-to-one has one phone in it; in a group the page
57
+ # says which participant it is speaking as.
58
+ def sender(db, conversation)
59
+ return conversation["number"] unless db.group?(conversation)
60
+
61
+ chosen = Numbers.normalize(params[:as])
62
+ conversation["participants"].include?(chosen) ? chosen : conversation["participants"].first
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ # The fake people: their names, whether they have an iPhone, and what
5
+ # becomes of a message sent to them.
6
+ class PeopleController < ApplicationController
7
+ def update
8
+ number = Numbers.normalize(params[:number])
9
+ unless number.empty?
10
+ store.write do |db|
11
+ person = db.ensure_person(number, "service" => params[:service], "outcome" => params[:outcome])
12
+ person["name"] = params[:name].to_s.strip.presence if params.key?(:name)
13
+ end
14
+ end
15
+
16
+ request.xhr? ? head(:no_content) : redirect_back(fallback_location: root_path)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PocketPhone
4
+ module ApplicationHelper
5
+ TAPBACKS = { "love" => "❤️", "like" => "👍", "dislike" => "👎",
6
+ "laugh" => "😂", "emphasize" => "‼️", "question" => "❓" }.freeze
7
+ IMAGE = /\.(png|jpe?g|gif|webp|heic|avif|svg|bmp)\z/i
8
+ VIDEO = /\.(mp4|mov|m4v|webm)\z/i
9
+ AUDIO = /\.(mp3|m4a|wav|caf|aac|ogg|amr)\z/i
10
+ # Minutes of quiet before iMessage puts a timestamp between two messages.
11
+ TIMESTAMP_GAP = 15 * 60
12
+
13
+ def pp_base = root_path.chomp("/")
14
+
15
+ # --- names ----------------------------------------------------------------
16
+
17
+ def pp_person_name(number)
18
+ @db.people.dig(number, "name").presence || Numbers.pretty(number)
19
+ end
20
+
21
+ def pp_line_name(line)
22
+ @db.lines.dig(line, "name").presence || Numbers.pretty(line)
23
+ end
24
+
25
+ def pp_sender_name(message)
26
+ message["direction"] == "app" ? pp_line_name(message["from_number"]) : pp_person_name(message["from_number"])
27
+ end
28
+
29
+ def pp_title(conversation)
30
+ return pp_person_name(conversation["number"]) unless @db.group?(conversation)
31
+
32
+ conversation["name"].presence || conversation["participants"].map { |n| pp_person_name(n).split.first }.to_sentence
33
+ end
34
+
35
+ def pp_avatar(label, key: label, photo: nil, size: nil)
36
+ style = "--hue:#{key.to_s.sum % 360};"
37
+ style += "width:#{size}px;height:#{size}px;font-size:#{(size * 0.4).round}px;" if size
38
+ return tag.span(image_tag(photo, alt: ""), class: "pp-avatar", style: style) if photo.present?
39
+
40
+ letters = label.to_s.scan(/\b[[:alpha:]]/).first(2).join.upcase
41
+ letters = label.to_s.match?(/\p{Emoji_Presentation}/) ? label : "#" if letters.empty?
42
+ tag.span(letters, class: "pp-avatar", style: style)
43
+ end
44
+
45
+ # --- time -----------------------------------------------------------------
46
+
47
+ def pp_clock(iso)
48
+ Time.iso8601(iso).localtime.strftime("%-l:%M %p")
49
+ rescue ArgumentError, TypeError
50
+ ""
51
+ end
52
+
53
+ def pp_stamp(iso)
54
+ time = Time.iso8601(iso).localtime
55
+ day = if time.to_date == Date.today then "Today"
56
+ elsif time.to_date == Date.today - 1 then "Yesterday"
57
+ else time.strftime("%a, %b %-d")
58
+ end
59
+ safe_join([tag.strong(day), " ", time.strftime("%-l:%M %p")])
60
+ rescue ArgumentError, TypeError
61
+ ""
62
+ end
63
+
64
+ def pp_gap?(previous, message)
65
+ return true if previous.nil?
66
+
67
+ Time.iso8601(message["created_at"]) - Time.iso8601(previous["created_at"]) > TIMESTAMP_GAP
68
+ rescue ArgumentError, TypeError
69
+ false
70
+ end
71
+
72
+ # --- message bodies -------------------------------------------------------
73
+
74
+ def pp_linkify(text)
75
+ pieces = text.to_s.split(/(https?:\/\/[^\s<>"]+)/i).map do |piece|
76
+ next piece unless piece.match?(/\Ahttps?:\/\//i)
77
+
78
+ url = piece.sub(LinkPreview::TRAILING, "")
79
+ safe_join([link_to(url, url, target: "_blank", rel: "noopener"), piece[url.length..]])
80
+ end
81
+ safe_join(pieces)
82
+ end
83
+
84
+ def pp_media(url)
85
+ path = url.to_s.split(/[?#]/).first
86
+ if path.match?(VIDEO)
87
+ tag.video(src: url, controls: true, class: "pp-media")
88
+ elsif path.match?(AUDIO)
89
+ tag.audio(src: url, controls: true, class: "pp-audio")
90
+ elsif path.match?(IMAGE) || File.extname(path).empty?
91
+ # Many image URLs have no extension; if it turns out not to be one, the
92
+ # page swaps this for a link.
93
+ link_to(image_tag(url, alt: "", class: "pp-media", data: { fallback: File.basename(path) }),
94
+ url, target: "_blank", rel: "noopener")
95
+ else
96
+ link_to("📎 #{File.basename(path)}", url, class: "pp-file", target: "_blank", rel: "noopener")
97
+ end
98
+ end
99
+
100
+ def pp_snippet(message)
101
+ return "" unless message
102
+
103
+ text = message["content"].to_s.strip
104
+ text = "Attachment" if text.empty? && (message["media_url"].present? || message["media_urls"].present?)
105
+ text = message.dig("app_card", "appName").to_s if text.empty? && message["app_card"]
106
+ truncate(text, length: 70)
107
+ end
108
+
109
+ def pp_tapback(reaction) = TAPBACKS[reaction] || reaction
110
+
111
+ def pp_preview_for(message)
112
+ found = LinkPreview.detect(message["content"])
113
+ preview = found[:url] && @db.previews[found[:url]]
114
+ card = preview && preview["error"].nil? && (preview["title"].present? || preview["image"].present?)
115
+ { url: found[:url], reason: found[:reason], preview: preview, card: card ? preview : nil }
116
+ end
117
+
118
+ # --- what iMessage prints under a bubble ----------------------------------
119
+
120
+ # For a message the phone sent, the line under it: Delivered once the app
121
+ # accepted the webhook, Read once the app marked the thread read, and Not
122
+ # Delivered (with the reason) when the webhook failed.
123
+ def pp_receipt(message, conversation, last:)
124
+ hook = Array(message["webhooks"]).reverse.find { |w| w["type"] == "receive" }
125
+ return { text: "Sending…" } if hook.nil?
126
+
127
+ unless hook["status"].to_i.between?(200, 299)
128
+ reason = hook["error"] || "the webhook answered #{hook["status"]}"
129
+ return { text: "Not Delivered", failed: true, detail: reason }
130
+ end
131
+ return nil unless last
132
+
133
+ read = conversation["read_at"].to_s >= message["created_at"].to_s && message["service"] == "iMessage"
134
+ { text: read ? "Read #{pp_clock(conversation["read_at"])}" : "Delivered" }
135
+ end
136
+
137
+ def pp_webhook_outcome(hook)
138
+ hook["error"] || "HTTP #{hook["status"]}"
139
+ end
140
+
141
+ def pp_json(value)
142
+ JSON.pretty_generate(value)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,183 @@
1
+ <script>
2
+ (() => {
3
+ const root = document.querySelector("[data-pp]");
4
+ if (!root) return;
5
+
6
+ const base = root.dataset.base;
7
+ const conversation = root.dataset.conversation;
8
+ const isGroup = root.dataset.group === "1";
9
+ const token = document.querySelector('meta[name="csrf-token"]')?.content;
10
+ const sidebar = document.getElementById("pp-sidebar");
11
+ const head = document.getElementById("pp-head");
12
+ const messages = document.getElementById("pp-messages");
13
+ const compose = document.querySelector("[data-compose]");
14
+ const persona = document.querySelector("[data-persona]");
15
+ const open = new Set();
16
+ let version = "";
17
+
18
+ const post = (path, body) => fetch(base + path, {
19
+ method: "POST",
20
+ body,
21
+ headers: { "X-CSRF-Token": token || "", "X-Requested-With": "XMLHttpRequest" },
22
+ });
23
+ const form = (fields) => {
24
+ const data = new FormData();
25
+ Object.entries(fields).forEach(([key, value]) => value != null && data.append(key, value));
26
+ return data;
27
+ };
28
+ const remember = (key, value) => { try { localStorage.setItem(key, value); } catch (_) {} };
29
+ const recall = (key) => { try { return localStorage.getItem(key); } catch (_) { return null; } };
30
+ const atBottom = () => messages.scrollHeight - messages.scrollTop - messages.clientHeight < 80;
31
+ const toBottom = () => { messages.scrollTop = messages.scrollHeight; };
32
+
33
+ // In a group, the bubbles on the right are whoever you are speaking as.
34
+ function decorate() {
35
+ if (!messages) return;
36
+ if (isGroup && persona) {
37
+ messages.querySelectorAll(".pp-row[data-from]").forEach((row) => {
38
+ row.classList.toggle("mine", row.dataset.from === persona.value);
39
+ });
40
+ }
41
+ messages.querySelectorAll(".pp-row[data-handle]").forEach((row) => {
42
+ const shown = open.has(row.dataset.handle);
43
+ row.classList.toggle("open", shown);
44
+ row.querySelector(".pp-details").hidden = !shown;
45
+ });
46
+ const service = head?.querySelector("[data-service]")?.dataset.service;
47
+ if (compose && service) {
48
+ compose.classList.toggle("sms", service !== "iMessage");
49
+ compose.querySelector("textarea").placeholder = service === "iMessage" ? "iMessage" : "Text Message";
50
+ }
51
+ }
52
+
53
+ async function poll() {
54
+ let response;
55
+ try {
56
+ response = await fetch(`${base}/state?conversation=${encodeURIComponent(conversation)}&version=${encodeURIComponent(version)}`,
57
+ { headers: { Accept: "application/json" } });
58
+ } catch (_) { return; }
59
+ if (response.status !== 200) return;
60
+
61
+ const state = await response.json();
62
+ version = state.version;
63
+ if (state.gone) { window.location = base + "/"; return; }
64
+ if (sidebar.innerHTML !== state.sidebar) sidebar.innerHTML = state.sidebar;
65
+ if (head && state.head != null && head.dataset.html !== state.head && !head.contains(document.activeElement)) {
66
+ head.innerHTML = state.head;
67
+ head.dataset.html = state.head;
68
+ decorate();
69
+ }
70
+ if (messages && state.messages != null && messages.dataset.html !== state.messages) {
71
+ const stick = atBottom();
72
+ messages.innerHTML = state.messages;
73
+ messages.dataset.html = state.messages;
74
+ decorate();
75
+ if (stick) toBottom();
76
+ }
77
+ }
78
+
79
+ // --- sending ---------------------------------------------------------------
80
+
81
+ if (compose) {
82
+ const text = compose.querySelector("textarea");
83
+ const file = compose.querySelector('input[type="file"]');
84
+ const attached = compose.querySelector("[data-attached]");
85
+ let typingAt = 0;
86
+
87
+ const grow = () => { text.style.height = "auto"; text.style.height = `${text.scrollHeight}px`; };
88
+ const showFile = () => {
89
+ attached.hidden = !file.files.length;
90
+ attached.textContent = file.files.length ? `📎 ${file.files[0].name} ✕` : "";
91
+ };
92
+ const typing = (on) => {
93
+ if (isGroup) return;
94
+ if (on && Date.now() - typingAt < 4000) return;
95
+ typingAt = on ? Date.now() : 0;
96
+ post(`/conversations/${conversation}/typing`, form({ is_typing: on }));
97
+ };
98
+
99
+ const send = async () => {
100
+ if (!text.value.trim() && !file.files.length) return;
101
+ const data = new FormData(compose);
102
+ text.value = ""; file.value = ""; showFile(); grow(); typingAt = 0;
103
+ await post(`/conversations/${conversation}/messages`, data);
104
+ await poll();
105
+ toBottom();
106
+ text.focus();
107
+ };
108
+
109
+ compose.addEventListener("submit", (event) => { event.preventDefault(); send(); });
110
+ text.addEventListener("keydown", (event) => {
111
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); }
112
+ });
113
+ text.addEventListener("input", () => { grow(); typing(text.value.length > 0); });
114
+ file.addEventListener("change", showFile);
115
+ attached.addEventListener("click", () => { file.value = ""; showFile(); });
116
+ }
117
+
118
+ if (persona) {
119
+ const key = `pocket_phone:as:${conversation}`;
120
+ const saved = recall(key);
121
+ if (saved && [...persona.options].some((option) => option.value === saved)) persona.value = saved;
122
+ persona.addEventListener("change", () => { remember(key, persona.value); decorate(); });
123
+ }
124
+
125
+ // --- everything clickable --------------------------------------------------
126
+
127
+ document.addEventListener("click", async (event) => {
128
+ const target = event.target.closest("[data-details], [data-tapback], [data-redeliver], [data-open-dialog], [data-close-dialog]");
129
+ if (!target) return;
130
+ const row = target.closest(".pp-row");
131
+ const dialog = document.querySelector("[data-dialog]");
132
+
133
+ if (target.matches("[data-open-dialog]")) { dialog.showModal(); return; }
134
+ if (target.matches("[data-close-dialog]")) { dialog.close(); return; }
135
+ if (target.matches("[data-details]")) {
136
+ open.has(row.dataset.handle) ? open.delete(row.dataset.handle) : open.add(row.dataset.handle);
137
+ decorate();
138
+ return;
139
+ }
140
+ if (target.matches("[data-tapback]")) {
141
+ await post(`/messages/${row.dataset.handle}/reactions`,
142
+ form({ reaction: target.dataset.tapback, as: persona ? persona.value : null }));
143
+ } else if (target.matches("[data-redeliver]")) {
144
+ await post(`/messages/${row.dataset.handle}/redeliver`, form({}));
145
+ }
146
+ poll();
147
+ });
148
+
149
+ document.addEventListener("submit", (event) => {
150
+ const target = event.target;
151
+ if (target.dataset.confirm && !window.confirm(target.dataset.confirm)) { event.preventDefault(); return; }
152
+ if (!target.dataset.remote) return;
153
+ event.preventDefault();
154
+ post(new URL(target.action).pathname.slice(base.length), new FormData(target)).then(poll);
155
+ });
156
+ document.addEventListener("change", (event) => {
157
+ if (!event.target.matches("[data-autosubmit]")) return;
158
+ event.target.form.requestSubmit();
159
+ event.target.blur();
160
+ });
161
+
162
+ // An attachment with no extension is tried as an image first; when it is
163
+ // not one, show it as a file instead.
164
+ document.addEventListener("error", (event) => {
165
+ const image = event.target;
166
+ if (!(image instanceof HTMLImageElement) || !image.dataset.fallback || !image.parentElement) return;
167
+ image.parentElement.className = "pp-file";
168
+ image.parentElement.textContent = `📎 ${image.dataset.fallback}`;
169
+ }, true);
170
+ if (messages) {
171
+ messages.addEventListener("load", () => { if (atBottom() || !messages.dataset.settled) toBottom(); }, true);
172
+ setTimeout(() => { messages.dataset.settled = "1"; }, 1500);
173
+ decorate();
174
+ toBottom();
175
+ }
176
+
177
+ document.querySelector("[data-dialog]")?.addEventListener("click", (event) => {
178
+ if (event.target === event.currentTarget) event.currentTarget.close();
179
+ });
180
+
181
+ setInterval(poll, 1000);
182
+ })();
183
+ </script>